diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 44c3d5bae6..c44bbc69c4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -350,7 +350,7 @@ jobs: name: macOS build ${{ matrix.arch }} needs: version if: needs.version.outputs.build == 'true' - runs-on: macos-14 + runs-on: macos-15 strategy: fail-fast: false matrix: @@ -384,7 +384,7 @@ jobs: name: macOS universal needs: [version, macos-build] if: needs.version.outputs.build == 'true' - runs-on: macos-14 + runs-on: macos-15 env: SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }} SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 5ea03947d4..71fad90a87 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -189,11 +189,7 @@ Editable drawing code, native projects and shipped exports are collected under ` | assets/shaders/viewport.wgsl | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 | | assets/styles/ui/palettes.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 | | assets/styles/ui/themes/gruvbox-dark.json | SolidCraft contributors | original SolidCraft theme file mapping SolidCraft's UI colours to the Gruvbox palette's colour values (Gruvbox by Pavel Pertsev, https://github.com/morhetz/gruvbox, MIT) | MIT OR Apache-2.0 | -| assets/styles/ui/themes/gruvbox-dark-hard.json | SolidCraft contributors | original SolidCraft theme file mapping SolidCraft's UI colours to the Gruvbox palette's colour values (Gruvbox by Pavel Pertsev, https://github.com/morhetz/gruvbox, MIT) | MIT OR Apache-2.0 | -| assets/styles/ui/themes/gruvbox-dark-soft.json | SolidCraft contributors | original SolidCraft theme file mapping SolidCraft's UI colours to the Gruvbox palette's colour values (Gruvbox by Pavel Pertsev, https://github.com/morhetz/gruvbox, MIT) | MIT OR Apache-2.0 | | assets/styles/ui/themes/gruvbox-light.json | SolidCraft contributors | original SolidCraft theme file mapping SolidCraft's UI colours to the Gruvbox palette's colour values (Gruvbox by Pavel Pertsev, https://github.com/morhetz/gruvbox, MIT) | MIT OR Apache-2.0 | -| assets/styles/ui/themes/gruvbox-light-hard.json | SolidCraft contributors | original SolidCraft theme file mapping SolidCraft's UI colours to the Gruvbox palette's colour values (Gruvbox by Pavel Pertsev, https://github.com/morhetz/gruvbox, MIT) | MIT OR Apache-2.0 | -| assets/styles/ui/themes/gruvbox-light-soft.json | SolidCraft contributors | original SolidCraft theme file mapping SolidCraft's UI colours to the Gruvbox palette's colour values (Gruvbox by Pavel Pertsev, https://github.com/morhetz/gruvbox, MIT) | MIT OR Apache-2.0 | | assets/styles/ui/themes/solidcraft-95.json | SolidCraft contributors | original SolidCraft theme file | MIT OR Apache-2.0 | | assets/styles/ui/themes/platinum-9.json | SolidCraft contributors | original SolidCraft theme file | MIT OR Apache-2.0 | | assets/styles/ui/themes/cupertino.json | SolidCraft contributors | original SolidCraft theme file | MIT OR Apache-2.0 | diff --git a/Cargo.lock b/Cargo.lock index 26a62c9c62..2a1f185caa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4208,7 +4208,7 @@ dependencies = [ [[package]] name = "solidcraft" -version = "0.28.0" +version = "0.29.0" dependencies = [ "block2 0.6.2", "eframe", @@ -4245,11 +4245,11 @@ dependencies = [ [[package]] name = "solidcraft-3dx-client" -version = "0.28.0" +version = "0.29.0" [[package]] name = "solidcraft-cli" -version = "0.28.0" +version = "0.29.0" dependencies = [ "image", "log", @@ -4261,7 +4261,7 @@ dependencies = [ [[package]] name = "solidcraft-derive" -version = "0.28.0" +version = "0.29.0" dependencies = [ "proc-macro2", "quote", @@ -4270,7 +4270,7 @@ dependencies = [ [[package]] name = "solidcraft-doc" -version = "0.28.0" +version = "0.29.0" dependencies = [ "log", "serde", @@ -4287,7 +4287,7 @@ dependencies = [ [[package]] name = "solidcraft-engine" -version = "0.28.0" +version = "0.29.0" dependencies = [ "image", "log", @@ -4308,7 +4308,7 @@ dependencies = [ [[package]] name = "solidcraft-geom" -version = "0.28.0" +version = "0.29.0" dependencies = [ "serde", "serde_json", @@ -4317,7 +4317,7 @@ dependencies = [ [[package]] name = "solidcraft-gpu" -version = "0.28.0" +version = "0.29.0" dependencies = [ "image", "log", @@ -4337,7 +4337,7 @@ dependencies = [ [[package]] name = "solidcraft-io" -version = "0.28.0" +version = "0.29.0" dependencies = [ "cadmpeg-core", "cadmpeg-ir", @@ -4361,7 +4361,7 @@ dependencies = [ [[package]] name = "solidcraft-kernel" -version = "0.28.0" +version = "0.29.0" dependencies = [ "log", "proptest", @@ -4381,7 +4381,7 @@ dependencies = [ [[package]] name = "solidcraft-mcp" -version = "0.28.0" +version = "0.29.0" dependencies = [ "serde_json", "solidcraft-engine", @@ -4390,7 +4390,7 @@ dependencies = [ [[package]] name = "solidcraft-render" -version = "0.28.0" +version = "0.29.0" dependencies = [ "image", "moxcms", @@ -4404,7 +4404,7 @@ dependencies = [ [[package]] name = "solidcraft-sketch" -version = "0.28.0" +version = "0.29.0" dependencies = [ "harfrust", "serde", @@ -4417,14 +4417,14 @@ dependencies = [ [[package]] name = "solidcraft-space-mouse" -version = "0.28.0" +version = "0.29.0" dependencies = [ "serde", ] [[package]] name = "solidcraft-ui-egui" -version = "0.28.0" +version = "0.29.0" dependencies = [ "arboard", "egui", @@ -4443,6 +4443,7 @@ dependencies = [ "solidcraft-gpu", "solidcraft-space-mouse", "syn 2.0.119", + "sys-locale", "tiny-skia", "toml", "ttf-parser", @@ -4454,7 +4455,7 @@ dependencies = [ [[package]] name = "solidcraft-web" -version = "0.28.0" +version = "0.29.0" dependencies = [ "eframe", "egui", @@ -4558,6 +4559,18 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "js-sys", + "libc", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -4565,7 +4578,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.5", "windows-sys 0.61.2", @@ -5997,7 +6010,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.28.0" +version = "0.29.0" dependencies = [ "proc-macro2", "serde", diff --git a/Cargo.toml b/Cargo.toml index 82ea8119b0..13a3f37788 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"] exclude = ["vendor"] [workspace.package] -version = "0.28.0" +version = "0.29.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" diff --git a/THIRD-PARTY-LICENSES.txt b/THIRD-PARTY-LICENSES.txt index 3ecc7d0817..a85847aa07 100644 --- a/THIRD-PARTY-LICENSES.txt +++ b/THIRD-PARTY-LICENSES.txt @@ -1,6 +1,6 @@ Third-party software in SolidCraft -SolidCraft (the desktop app, solidcraft-cli and the web app) is built from the 560 crates below. +SolidCraft (the desktop app, solidcraft-cli and the web app) is built from the 561 crates below. Generated by `cargo xtask licences` from Cargo.lock and the crates' own licence files. SolidCraft's own licence is in LICENSE-MIT and LICENSE-APACHE, and NOTICE. @@ -403,6 +403,7 @@ syn 1.0.109 (MIT OR Apache-2.0) syn 2.0.119 (MIT OR Apache-2.0) syn 3.0.6 (MIT OR Apache-2.0) synstructure 0.13.2 (MIT) +sys-locale 0.3.2 (MIT OR Apache-2.0) tempfile 3.27.0 (MIT OR Apache-2.0) termcolor 1.4.1 (Unlicense OR MIT) thiserror 1.0.69 (MIT OR Apache-2.0) @@ -1836,8 +1837,8 @@ approx 0.4.0, as-raw-xcb-connection 1.0.1, atspi 0.29.0, atspi-common 0.13.0, at cgmath 0.18.0, chardetng 0.1.17, codespan-reporting 0.13.1, encoding_rs 0.8.42, kurbo 0.13.1, multiversion_no_op 1.0.0, nohash-hasher 0.2.0, peniko 0.6.1, polycool 0.4.0, raw-window-metal 1.1.0, rustls-platform-verifier 0.7.1, self_cell 1.3.0, siphasher 1.0.4, spade 2.15.1, static_assertions -1.1.0, ureq 3.4.2, ureq-proto 0.6.4, utf8_iter 1.0.4, x11rb 0.13.2, x11rb-protocol 0.13.2, zeroize -1.9.1, zeroize_derive 1.5.0, zune-core 0.5.3, zune-jpeg 0.5.15 +1.1.0, sys-locale 0.3.2, ureq 3.4.2, ureq-proto 0.6.4, utf8_iter 1.0.4, x11rb 0.13.2, x11rb-protocol +0.13.2, zeroize 1.9.1, zeroize_derive 1.5.0, zune-core 0.5.3, zune-jpeg 0.5.15 ================================================================================ Apache License @@ -13570,6 +13571,32 @@ The above copyright notice and this permission notice shall be included in all c THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +================================================================================ +sys-locale 0.3.2 +================================================================================ + +MIT License + +Copyright (c) 2021 1Password + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + ================================================================================ tempfile 3.27.0 ================================================================================ diff --git a/apps/solidcraft-cli/tests/run_results.rs b/apps/solidcraft-cli/tests/run_results.rs index 51fe28534c..c6e44c6897 100644 --- a/apps/solidcraft-cli/tests/run_results.rs +++ b/apps/solidcraft-cli/tests/run_results.rs @@ -229,7 +229,9 @@ fn known_pending_concepts_and_released_files_round_trip_with_inert_metadata() { assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); let reopened = io::read_design(&std::fs::read(&destination).unwrap()).unwrap(); assert_eq!(reopened.metadata, document.metadata); - assert_eq!(reopened.name, document.name); + // #4907: `run --save` maps to Save As, and a borrowed design name + // follows the destination file's stem instead of keeping the old name. + assert_eq!(reopened.name, format!("resaved-{index}")); assert_eq!(reopened.features, document.features); assert_eq!(std::fs::read(&source).unwrap(), bytes); } diff --git a/apps/solidcraft-cli/tests/script_roundtrip.rs b/apps/solidcraft-cli/tests/script_roundtrip.rs index 568c4bdb31..e959c6e89a 100644 --- a/apps/solidcraft-cli/tests/script_roundtrip.rs +++ b/apps/solidcraft-cli/tests/script_roundtrip.rs @@ -123,3 +123,42 @@ fn a_design_naming_a_sketch_and_its_extrude_alike_replays_from_its_script() { assert_eq!(names, ["Block", "Block"], "{first:#}"); assert_eq!(second["commands"], first["commands"], "the replayed design's script has the same steps"); } + +/// #10230: script export must disclose the print-face state that replay leaves out. +#[test] +fn script_export_warns_about_omitted_print_faces_on_stderr() { + let dir = std::env::temp_dir().join(format!("solidcraft-print-face-script-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let saved = dir.join("block.sc"); + let exported = dir.join("block.json"); + let mut s = Session::default(); + s.execute("solid.box", &json!({"length": 20, "width": 10, "height": 5, "body_name": "Block"})).unwrap(); + s.execute("body.print_face", &json!({"body": "Block", "point": [10, 5, 0]})).unwrap(); + s.execute("file.save", &json!({"path": saved})).unwrap(); + let native = std::fs::read(&saved).unwrap(); + + for to_file in [false, true] { + let mut cmd = std::process::Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")); + cmd.arg("script").arg(&saved); + if to_file { + cmd.arg("--out").arg(&exported); + } + let out = cmd.output().unwrap(); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(out.status.success(), "{stderr}"); + assert!(stderr.contains("note: the script leaves out print-face assignments (body.print_face)"), "{stderr}"); + let script = if to_file { + assert!(out.stdout.is_empty(), "--out must keep stdout empty"); + std::fs::read(&exported).unwrap() + } else { + out.stdout + }; + let script: Value = serde_json::from_slice(&script).unwrap(); + let mut replayed = Session::default(); + replayed.run_script(&script).unwrap(); + assert!(replayed.doc.print_faces.is_empty()); + assert_eq!(replayed.doc.features, s.doc.features); + assert_eq!(std::fs::read(&saved).unwrap(), native, "export must not change the source design"); + } + std::fs::remove_dir_all(dir).unwrap(); +} diff --git a/apps/solidcraft-web/src/main.rs b/apps/solidcraft-web/src/main.rs index 6ebe14513d..641cca8f68 100644 --- a/apps/solidcraft-web/src/main.rs +++ b/apps/solidcraft-web/src/main.rs @@ -684,7 +684,7 @@ mod web { /// Whether the shell already has the on-demand face the live language needs. fn script_font_ready(&self) -> bool { - match solidcraft_ui_egui::fonts::web_script_font_kind(&self.app.preferences.language) { + match solidcraft_ui_egui::fonts::web_script_font_kind(solidcraft_ui_egui::i18n::effective_language(&self.app.preferences.language)) { Some("zh") => self.chinese_bytes.is_some() && self.chinese_kind == Some("zh"), Some("zh-TW") => self.chinese_bytes.is_some() && self.chinese_kind == Some("zh-TW"), Some("ja") => self.cjk_bytes.is_some(), @@ -726,7 +726,8 @@ mod web { false } }; - if !installed && solidcraft_ui_egui::fonts::needs_cjk_font(&self.app.preferences.language) { + let effective = solidcraft_ui_egui::i18n::effective_language(&self.app.preferences.language); + if !installed && solidcraft_ui_egui::fonts::needs_cjk_font(effective) { log::warn!("script font unavailable; showing English for this session"); } self.font.finished(installed, &mut self.app.preferences.language); @@ -734,12 +735,13 @@ mod web { ctx.request_repaint(); } let ready = self.script_font_ready(); + let effective = solidcraft_ui_egui::i18n::effective_language(&self.app.preferences.language); if self.font.tick(&mut self.app.preferences.language, ready) { - self.fetch_font(ctx, solidcraft_ui_egui::fonts::web_script_font_kind(&self.app.preferences.language).unwrap_or("ja")); + self.fetch_font(ctx, solidcraft_ui_egui::fonts::web_script_font_kind(effective).unwrap_or("ja")); } // The CJK face leads only a CJK UI: its Cyrillic glyphs are full-width, so switching // to Ukrainian (or English) must put the Latin face back in front. - if self.app.preferences.language != self.font_language { + if solidcraft_ui_egui::i18n::effective_language(&self.app.preferences.language) != self.font_language { self.apply_fonts(ctx); } } @@ -747,17 +749,13 @@ mod web { /// Set up the UI fonts for the live language, keeping fetched script fonts as fallbacks /// (Chinese leads a Chinese UI; see [`solidcraft_ui_egui::fonts::install_web_script_fonts`]). fn apply_fonts(&mut self, ctx: &egui::Context) { + let effective = solidcraft_ui_egui::i18n::effective_language(&self.app.preferences.language); if self.cjk_bytes.is_none() && self.chinese_bytes.is_none() { solidcraft_ui_egui::fonts::setup_fonts(ctx); } else { - solidcraft_ui_egui::fonts::install_web_script_fonts( - ctx, - self.cjk_bytes.clone(), - self.chinese_bytes.clone(), - &self.app.preferences.language, - ); + solidcraft_ui_egui::fonts::install_web_script_fonts(ctx, self.cjk_bytes.clone(), self.chinese_bytes.clone(), effective); } - self.font_language.clone_from(&self.app.preferences.language); + self.font_language = effective.to_owned(); } /// CJK text on screen with no Japanese font yet (an English UI showing a Japanese name): diff --git a/apps/solidcraft/src/main.rs b/apps/solidcraft/src/main.rs index 3d18f9e936..81b0398c77 100644 --- a/apps/solidcraft/src/main.rs +++ b/apps/solidcraft/src/main.rs @@ -99,6 +99,86 @@ struct App { nav_bridge: nav_bridge::NavBridge, /// Where the next file dialog opens: it follows the open design (#7756). dialog_folder: DialogFolder, + /// Settings go to `app.ron` in the frame they change (#8275). + prefs_writer: PrefsWriter, +} + +/// Settings written to the settings file soon after they change (#8275). eframe saves them +/// only every 30 s and when the window closes normally, so a change was lost whenever the app +/// did not get that far: ended from the Task Manager, stopped by a sign-out or shutdown, or +/// crashed or hung in the graphics driver on the way out. +/// +/// Some settings change continuously (a panel's width while its edge is dragged), so the file +/// is written at most once per [`PrefsWriter::INTERVAL`], and a change made in between is written +/// when that interval ends. +#[derive(Default)] +struct PrefsWriter { + /// The settings written (or read at start) last. + written: Option, + /// The previous frame had input that can change settings: what it changed shows in this + /// frame's settings. + input_before: bool, + /// When the settings were last written by this writer. + last_write: Option, + /// A change waits until then to be written. + pending_until: Option, +} + +impl PrefsWriter { + /// The least time between two writes of the settings file. + const INTERVAL: std::time::Duration = std::time::Duration::from_secs(1); + + /// The settings as they were read at start: they are on disk already. + fn read(prefs: String) -> Self { + Self { written: Some(prefs), ..Self::default() } + } + + /// Whether this frame's settings are worth comparing: input that can change them came in this + /// frame or the one before, or a waiting change's time has come. Pointer motion alone isn't + /// such input; eframe's own save still catches anything else. + fn due(&mut self, input: bool, now: std::time::Instant) -> bool { + let due = input || self.input_before || self.pending_until.is_some_and(|at| now >= at); + self.input_before = input; + due + } + + /// Write `prefs` and flush them to disk if they differ from the settings written last and the + /// last write is at least [`Self::INTERVAL`] old. Returns how long a change waits otherwise: + /// the caller asks for a frame then, so an idle app still writes it. + fn store(&mut self, storage: &mut dyn eframe::Storage, prefs: String, now: std::time::Instant) -> Option { + if self.written.as_deref() == Some(prefs.as_str()) { + self.pending_until = None; + return None; + } + if let Some(wait) = self.last_write.map(|at| Self::INTERVAL.saturating_sub(now.saturating_duration_since(at))).filter(|w| !w.is_zero()) { + self.pending_until = Some(now + wait); + return Some(wait); + } + storage.set_string(solidcraft_ui_egui::prefs::STORAGE_KEY, prefs.clone()); + storage.flush(); + self.written = Some(prefs); + self.last_write = Some(now); + self.pending_until = None; + None + } +} + +/// Input that can change a setting: a click, a key, typed or pasted text, a wheel or a pinch. +/// Pointer motion, which comes every frame while the mouse moves, cannot by itself. +fn changes_settings(event: &egui::Event) -> bool { + matches!( + event, + egui::Event::PointerButton { .. } + | egui::Event::Key { .. } + | egui::Event::Text(_) + | egui::Event::Paste(_) + | egui::Event::Cut + | egui::Event::Ime(_) + | egui::Event::MouseWheel { .. } + | egui::Event::Zoom(_) + | egui::Event::Rotate(_) + | egui::Event::AccessKitActionRequest(_) + ) } impl eframe::App for App { @@ -186,11 +266,21 @@ impl eframe::App for App { solidcraft_ui_egui::space_mouse::buttons_hook(&mut self.solve, ctx, self.space_mouse.buttons(), raw); self.solve.raw_input_hook(raw); } - fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { content_ui(&mut self.solve, ui); + // After the frame's UI, so a setting changed in it (Preferences' OK) is written now. + let now = std::time::Instant::now(); + if self.prefs_writer.due(ui.input(|i| i.events.iter().any(changes_settings)), now) + && let Some(storage) = frame.storage_mut() + && let Some(wait) = self.prefs_writer.store(storage, self.solve.prefs(), now) + { + ui.ctx().request_repaint_after(wait); + } } fn save(&mut self, storage: &mut dyn eframe::Storage) { - storage.set_string(solidcraft_ui_egui::prefs::STORAGE_KEY, self.solve.prefs()); + let prefs = self.solve.prefs(); + storage.set_string(solidcraft_ui_egui::prefs::STORAGE_KEY, prefs.clone()); + self.prefs_writer.written = Some(prefs); } fn on_exit(&mut self) { // The agent chat's agent and everything it started end with the app. @@ -728,6 +818,24 @@ fn msaa() -> Option { /// (`solidcraft_ui_egui::titlebar`). macOS keeps its traffic lights over the integrated bar. const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos"); +/// Linux: the window manager's own decorations (server-side, or the compositor's frame) can be +/// asked for instead of the application bar ([`titlebar_choice`]). Elsewhere always `true`. +fn app_titlebar_wanted() -> bool { + if !cfg!(all(unix, not(target_os = "macos"))) { + return true; + } + let setting = graphics().and_then(|v| v.get("titlebar").and_then(serde_json::Value::as_str).map(str::to_string)); + titlebar_choice(solidcraft_engine::geom::env::var("TITLEBAR").as_deref(), setting.as_deref()) +} + +/// Linux: whether the application bar is the title bar. `SOLIDCRAFT_TITLEBAR` (else +/// `graphics.json`'s `titlebar`) set to `system` gives the window the desktop's own title bar and +/// buttons; `app` (or anything else, or nothing) keeps the application bar as the title bar. +fn titlebar_choice(env: Option<&str>, setting: Option<&str>) -> bool { + let choice = env.map(str::trim).filter(|v| !v.is_empty()).or(setting.map(str::trim)); + !choice.is_some_and(|v| v.eq_ignore_ascii_case("system")) +} + const DEPTH_BITS: u8 = 24; /// A 32-bit float depth buffer, drawn with reversed depth (1 near, 0 far) so its precision follows /// the distance from the eye (#1434). Only where every adapter offers it with a stencil @@ -898,7 +1006,7 @@ fn main() -> eframe::Result { eframe::egui_wgpu::WgpuSetup::CreateNew(setup) => setup.instance_descriptor.backends, _ => eframe::wgpu::Backends::all(), }; - let custom_titlebar = CUSTOM_TITLEBAR && !startup::system_title_bar(cfg!(target_os = "windows"), started); + let custom_titlebar = CUSTOM_TITLEBAR && app_titlebar_wanted() && !startup::system_title_bar(cfg!(target_os = "windows"), started); eframe::egui_wgpu::set_startup_hook(startup_step); solidcraft_engine::crash::phase("window"); let options = eframe::NativeOptions { @@ -1018,6 +1126,12 @@ fn main() -> eframe::Result { }) .collect(); let info = rs.adapter.get_info(); + if !app.preferences.graphics_adapter.is_empty() && !info.name.eq_ignore_ascii_case(app.preferences.graphics_adapter.as_str()) { + eprintln!( + "SolidCraft graphics: preferred adapter {:?} was not used; using {:?} instead", + app.preferences.graphics_adapter, info.name + ); + } let samples = scene_msaa_request(saved_samples); let supported = scene_sample_choices(depth_bits, STENCIL_BITS, |depth| { eframe::egui_wgpu::supported_msaa_samples(&rs.adapter, &rs.device, rs.target_format, depth) @@ -1122,6 +1236,8 @@ fn main() -> eframe::Result { mark("app created"); solidcraft_engine::crash::phase("first frame"); Ok(Box::new(App { + // Before `app` moves: the settings as read, already on disk. + prefs_writer: PrefsWriter::read(app.prefs()), solve: app, starting, frames: 0, @@ -1313,6 +1429,148 @@ mod backend_tests { assert!(!prefer_x11(true, true, Some("GNOME"), over(Some("wayland")))); assert!(prefer_x11(true, true, Some("niri"), over(Some("x11")))); } + + #[test] + fn the_desktops_title_bar_only_when_asked_for() { + use super::titlebar_choice; + // Nothing set: the application bar is the title bar, as before. + assert!(titlebar_choice(None, None)); + // graphics.json's `titlebar`. + assert!(!titlebar_choice(None, Some("system"))); + assert!(titlebar_choice(None, Some("app"))); + // SOLIDCRAFT_TITLEBAR wins over the file; empty counts as unset; case and spaces don't matter. + assert!(!titlebar_choice(Some(" System "), Some("app"))); + assert!(titlebar_choice(Some("app"), Some("system"))); + assert!(!titlebar_choice(Some(""), Some("system"))); + // Anything unknown keeps the application bar. + assert!(titlebar_choice(Some("ssd"), None)); + } +} + +#[cfg(test)] +mod prefs_writer_tests { + use super::PrefsWriter; + use solidcraft_ui_egui::prefs::{NavigatorStyle, STORAGE_KEY}; + use std::collections::HashMap; + use std::time::{Duration, Instant}; + + /// eframe's settings file: what was flushed to disk is what the next start reads. + #[derive(Default)] + struct Disk { + pending: HashMap, + flushed: HashMap, + flushes: usize, + } + + impl eframe::Storage for Disk { + fn get_string(&self, key: &str) -> Option { + self.pending.get(key).cloned() + } + fn set_string(&mut self, key: &str, value: String) { + self.pending.insert(key.into(), value); + } + fn remove_string(&mut self, key: &str) { + self.pending.remove(key); + } + fn flush(&mut self) { + self.flushed = self.pending.clone(); + self.flushes += 1; + } + } + + fn new_app() -> solidcraft_ui_egui::SolveApp { + solidcraft_ui_egui::SolveApp::new(solidcraft_engine::Session::default(), solidcraft_ui_egui::Services::default()) + } + + /// #8275: Preferences' View navigator and Display quality, changed and confirmed, are on disk + /// in that frame, so the next start has them even when this one never closes normally (ended + /// from the Task Manager, a sign-out, a hang or crash in the graphics driver on the way out). + #[test] + fn changed_settings_reach_the_disk_in_the_frame_they_change() { + let mut app = new_app(); + let mut disk = Disk::default(); + let mut writer = PrefsWriter::read(app.prefs()); + let now = Instant::now(); + assert_eq!(writer.store(&mut disk, app.prefs(), now), None); + assert_eq!(disk.flushes, 0, "the settings read at start are not written again"); + app.preferences.navigator_style = NavigatorStyle::Cube; + app.preferences.display_quality = "fine".into(); + assert!(writer.due(true, now), "a frame with input is checked"); + assert_eq!(writer.store(&mut disk, app.prefs(), now), None); + assert_eq!(disk.flushes, 1, "flushed at once, not left for eframe's next save"); + assert_eq!(writer.store(&mut disk, app.prefs(), now + Duration::from_secs(5)), None); + assert_eq!(disk.flushes, 1, "unchanged settings are not written again"); + + // The next start reads them. + let mut next = new_app(); + let stored = solidcraft_ui_egui::prefs::stored(|k| disk.flushed.get(k).cloned()).expect("settings on disk"); + next.load_prefs(&stored); + assert_eq!(next.preferences.navigator_style, NavigatorStyle::Cube); + assert_eq!(next.preferences.display_quality, "fine"); + assert!(disk.flushed.contains_key(STORAGE_KEY)); + } + + /// A frame without input is checked only right after one with input (whose clicks the UI + /// handled in that frame); after that, idle frames cost nothing. Pointer motion, which comes + /// every frame while the mouse moves, doesn't count as input. + #[test] + fn frames_are_checked_with_input_and_once_after_it() { + let now = Instant::now(); + let mut writer = PrefsWriter::default(); + assert!(!writer.due(false, now)); + assert!(writer.due(true, now)); + assert!(writer.due(false, now), "the frame after input"); + assert!(!writer.due(false, now)); + + let click = egui::Event::PointerButton { + pos: egui::Pos2::ZERO, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }; + assert!(super::changes_settings(&click)); + assert!(super::changes_settings(&egui::Event::Text("5".into()))); + assert!(!super::changes_settings(&egui::Event::PointerMoved(egui::Pos2::ZERO))); + assert!(!super::changes_settings(&egui::Event::MouseMoved(egui::Vec2::X))); + } + + /// A setting that changes in every frame (a panel's width while its edge is dragged) doesn't + /// write the file in every frame: at most once a second, and the last change is on disk at + /// most a second after it was made, with a frame asked for then so an idle app writes it. + #[test] + fn a_continuous_change_is_written_once_a_second_and_its_end_soon_after() { + let mut app = new_app(); + let mut disk = Disk::default(); + let mut writer = PrefsWriter::read(app.prefs()); + let start = Instant::now(); + let frame = Duration::from_millis(16); + let mut asked = None; + // 1.5 s of dragging at 60 frames a second, with input in every frame. + for n in 0..94u32 { + let now = start + frame * n; + app.preferences.browser_width = 200.0 + n as f32; + assert!(writer.due(true, now)); + asked = writer.store(&mut disk, app.prefs(), now); + } + assert_eq!(disk.flushes, 2, "the first change at once, then one a second later"); + let wait = asked.expect("the last width waits, and a frame is asked for when it can be written"); + assert!(wait <= PrefsWriter::INTERVAL); + + // The drag ends: no more input. Frames before the wait is over don't even look. + let end = start + frame * 93; + assert!(writer.due(false, end + frame), "the frame right after input"); + assert!(!writer.due(false, end + frame * 2), "an idle frame before the wait is over"); + // The frame asked for writes the last width. + let then = end + wait; + assert!(writer.due(false, then)); + assert_eq!(writer.store(&mut disk, app.prefs(), then), None); + assert_eq!(disk.flushes, 3); + let mut next = new_app(); + let stored = solidcraft_ui_egui::prefs::stored(|k| disk.flushed.get(k).cloned()).expect("settings on disk"); + next.load_prefs(&stored); + assert_eq!(next.preferences.browser_width, 293.0, "the width the drag ended at"); + assert!(!writer.due(false, then + frame), "nothing waits any more"); + } } #[cfg(test)] diff --git a/apps/solidcraft/src/startup.rs b/apps/solidcraft/src/startup.rs index 718b8388c4..683ded4ad2 100644 --- a/apps/solidcraft/src/startup.rs +++ b/apps/solidcraft/src/startup.rs @@ -137,8 +137,11 @@ pub struct AdapterChoice { pub compatible: bool, } -/// Choose only surface-compatible adapters. Names saved in preferences are exact; the -/// environment override follows wgpu's case-insensitive substring convention. +/// Choose an adapter by name. The environment override follows wgpu's case-insensitive +/// substring convention among surface-compatible adapters. A saved preference prefers an +/// exact (then case-insensitive) name among compatible adapters, and if the named card is +/// only listed as incompatible with the surface still picks it (#8341: otherwise the +/// preference is kept in the dialog while wgpu silently uses another GPU). /// No match leaves selection to the normal wgpu request. pub fn choose_adapter(adapters: &[AdapterChoice], environment: Option<&str>, preference: Option<&str>) -> Option { // wgpu's environment helper panics on a missing name; an override must remain recoverable. @@ -149,12 +152,14 @@ pub fn choose_adapter(adapters: &[AdapterChoice], environment: Option<&str>, pre return Some(index); } } - if let Some(name) = preference.filter(|s| !s.is_empty()) - && let Some((index, _)) = compatible().find(|(_, a)| a.name == name) - { - return Some(index); - } - None + let name = preference.filter(|s| !s.is_empty())?; + let find = |pred: &dyn Fn(&AdapterChoice) -> bool, only_compatible: bool| { + adapters.iter().enumerate().find(|(_, a)| (!only_compatible || a.compatible) && pred(a)).map(|(i, _)| i) + }; + find(&|a| a.name == name, true) + .or_else(|| find(&|a| a.name.eq_ignore_ascii_case(name), true)) + .or_else(|| find(&|a| a.name == name, false)) + .or_else(|| find(&|a| a.name.eq_ignore_ascii_case(name), false)) } /// Leave Automatic on wgpu's normal request path; named choices may defer to it too. @@ -416,7 +421,8 @@ mod tests { assert_eq!(choose_adapter(&cards, None, Some("removed")), None); assert_eq!(choose_adapter(&cards, Some("missing"), Some("Discrete small")), Some(1)); assert_eq!(choose_adapter(&cards, Some(""), Some("Discrete small")), Some(1)); - assert_eq!(choose_adapter(&cards, None, Some("Discrete")), None, "saved names are exact"); + assert_eq!(choose_adapter(&cards, None, Some("discrete small")), Some(1), "preference matches without regard to case"); + assert_eq!(choose_adapter(&cards, None, Some("Discrete")), None, "a preference is the full name, not a substring"); } #[test] @@ -425,11 +431,20 @@ mod tests { assert_eq!(choose_adapter(&cards, None, None), None); assert_eq!(choose_adapter(&cards, Some(""), Some("")), None); cards[2].compatible = false; - assert_eq!(choose_adapter(&cards, Some("large"), Some("Discrete large")), None); + // Environment still needs a compatible adapter; a preference keeps the named card (#8341). + assert_eq!(choose_adapter(&cards, Some("large"), Some("Discrete large")), Some(2)); assert_eq!(choose_adapter(&cards, Some("small"), None), Some(1)); assert_eq!(choose_adapter(&[], None, None), None); } + #[test] + fn preference_keeps_a_named_card_even_when_surface_says_incompatible() { + let mut cards = cards(); + cards[1].compatible = false; + assert_eq!(choose_adapter(&cards, None, Some("Discrete small")), Some(1)); + assert_eq!(choose_adapter(&cards, None, Some("discrete SMALL")), Some(1)); + } + #[test] fn automatic_adapter_preserves_the_previous_request_configuration() { use eframe::egui_wgpu::WgpuSetupCreateNew; diff --git a/assets/locales/ar.json b/assets/locales/ar.json index 696427396d..632aee7bd7 100644 --- a/assets/locales/ar.json +++ b/assets/locales/ar.json @@ -822,6 +822,7 @@ "cmd.appearance_pick_up": "التقاط المظهر", "cmd.appearance_replace": "استبدال المظهر", "cmd.assembly_bom": "قائمة المواد", + "cmd.assembly_mass_properties": "خصائص الكتلة", "cmd.body.print_face": "وجه الطباعة", "cmd.body_rename": "إعادة تسمية الجسم", "cmd.browser_collapse_all": "طي الكل", @@ -1247,6 +1248,7 @@ "cmd.view.render_browser": "متصفح التصيير", "cmd.view.render_dock": "نقل لوحة التصيير", "cmd.view.render_focus": "عرض تركيز التصيير", + "cmd.view.status_details": "تفاصيل حالة منطقة العرض", "cmd.view.status_items": "عناصر حالة منطقة العرض", "cmd.view_ambient_occlusion": "الإظلال المحيطي", "cmd.view_angle_readout": "زوايا العرض", @@ -1285,8 +1287,14 @@ "color.hex_hint": "#rrggbb", "color.hue": "درجة اللون", "color.linear": "عرض قيم اللون في النطاق الخطي 0-1", + "color.recent": "الألوان الأخيرة", + "color.recent_help": "تظهر هنا الألوان التي تختارها في أي حقل لون", + "color.saturation": "التشبع (بالمئة)", "color.saturation_value": "التشبع والقيمة", "color.selected": "اللون المحدد", + "color.step_down": "إنقاص خطوة", + "color.step_up": "زيادة خطوة", + "color.value": "القيمة، أي السطوع (بالمئة)", "config.column_filter_empty": "لا توجد أعمدة مطابقة", "contact.components_count": "{count} مكوّنات", "contact.enabled_label": "مفعّل", @@ -1333,6 +1341,7 @@ "dialog.dimension_name": "البُعد: {name}", "dialog.edit_title": "تعديل {title}", "dialog.no_shown_body": "لا يوجد جسم ظاهر لتغييره: أظهر جسمًا، أو اختر «جسم جديد»", + "dialog.pattern_sketch_not_object": "اختر أجسامًا أو ميزات: الرسم ليس أيًّا منهما. كرّر منحنياته بأداة النمط أو المرآة داخل الرسم، أو ابثقه أولًا.", "dialog.select": "↖ تحديد", "dialog.selected_count": "↖ {count} محدد", "dialog.selection_guidance": "حدد مُدخلًا لتغييره. انقر على العناصر المحددة لإزالتها؛ Ctrl/Cmd مع النقر يبدّل تحديدها أيضًا. راجع المعاينة ثم اضغط موافق.", @@ -1390,21 +1399,168 @@ "from the rule": "من القاعدة", "help.apache": "رخصة Apache 2.0", "help.appearance.assign": "يمنح الأجسام أو الأوجه أو المكونات المحددة لونًا أو مظهر مادة، من دون تغيير وزنها. استخدمه للتمييز بين القطع أو لمعاينة التشطيب.", + "help.assembly.bom": "يسرد قطع التصميم أو المكوّن مع أعدادها حسب رقم القطعة، ويمكنه حفظ القائمة بصيغة CSV أو JSON. استخدمه كقائمة قطع للطلب والتجميع.", "help.attributions": "الإسنادات (ATTRIBUTION.md)", "help.build": "الإصدار {version} · الإيداع {commit} · بُني في {date}", + "help.canvas.decal": "يضع صورة على وجه مستوٍ من النموذج مثل ملصق. استخدمه لإظهار تسمية أو شعار أو نمط مطبوع على قطعة.", + "help.canvas.edit": "يغيّر اللوحة المرجعية: موضعها أو حجمها أو زاويتها أو عتامتها أو انعكاسها أو ظهورها أو اسمها. استخدمه لمحاذاة صورة مرجعية مع الرسم الذي تتتبعه فوقها.", + "help.canvas.insert": "يضع صورة PNG أو JPEG على مستوى أو وجه كمرجع شبه شفاف. استخدمه لتتبع صورة أو مخطط في رسم، مع ضبط الصورة على مقاس معلوم.", + "help.component.break_link": "يفصل مكوّنًا مرتبطًا عن ملف تصميمه مع إبقاء أجسامه كما هي. استخدمه عندما ينبغي ألا تتبع القطعة تغييرات الملف الأصلي بعد الآن.", + "help.component.capture_position": "يحفظ مواضع القطع التي حركتها يدويًا لتصبح مواضعها المعتمدة. استخدمه بعد سحب قطع إلى أماكنها دون أن يمسكها أي مفصل.", + "help.component.create": "يضيف مكوّنًا جديدًا فارغًا إلى التصميم داخل المكوّن النشط، ما لم تختر مكوّنًا أبًا آخر. استخدمه لمنح قطعة جديدة في تجميع مكانًا خاصًا لأجسامها ورسومها.", "help.component.finish_edit": "يحفظ الجزء الذي تحرّره في مكانه، ويعود إلى تجميعه ويحدّث التجميع منه. استخدمه عند الانتهاء من تغييراتك على الجزء المرتبط.", + "help.component.from_bodies": "يحوّل كل جسم محدد إلى مكوّن مستقل يحمل اسم الجسم. استخدمه لتحويل تصميم من أجسام منفصلة إلى تجميع يمكن ربط قطعه وتحريكها.", + "help.component.insert_design": "يدرج تصميمًا محفوظًا آخر كمكوّن مرتبط بملفه، مع وضع أجسامه الصلبة كما هي فيه. استخدمه لإعادة استخدام قطعة في تجميع واستيراد تغييراتها اللاحقة عبر تحديث الروابط.", + "help.component.properties": "يعرض الحجم والكتلة ومركز الكتلة وعزوم القصور الذاتي لمكوّن، اعتمادًا على مواد أجسامه. استخدمه للتحقق من وزن التجميع وتوازنه.", + "help.component.revert_position": "يعيد القطع التي حركتها يدويًا إلى مواضعها السابقة، ويتجاهل التحركات التي لم تُثبّت مواضعها. استخدمه للتراجع عن التحركات التجريبية في تجميع.", + "help.component.update_links": "يعيد قراءة ملفات التصميم المرتبطة ويستبدل الأجسام التي تغيّرت. استخدمه بعد تعديل قطعة يستخدمها هذا التجميع.", + "help.config.configure": "يضيف معاملات أو ميزات أو مواد أو مظاهر كأعمدة في جدول التكوينات. استخدمه لبناء نسخ متعددة من تصميم واحد، مثل مقاسات قطعة.", + "help.config.preview": "يتحقق من أن صفًا في جدول التكوينات يُعاد بناؤه بنجاح، دون تغيير التصميم أو سجله. استخدمه قبل التبديل إلى نسخة لست متأكدًا منها.", + "help.config.rules": "يعرض قواعد جدول التكوينات وصفوفه والصف النشط حاليًا. استخدمه لمعرفة كيف أُعدّت نسخ التصميم.", + "help.config.table": "يعرض جدول التكوينات: صف لكل نسخة وعمود لكل معامل أو ميزة، ويبيّن هل ما زال التصميم مطابقًا للصف النشط. استخدمه لمراجعة نسخ التصميم.", "help.connect_note": "أعد تشغيل كل مساعد لتحميل خادم SolidCraft. نُسخ كل ملف احتياطيًا بامتداد .bak قبل تغييره.", "help.connect_title": "ربط مساعدي الذكاء الاصطناعي", + "help.construct.axis.edge": "ينشئ محورًا إنشائيًا على امتداد حافة مستقيمة أو خط رسم. استخدمه كمحور لعملية دوران أو لنمط دائري أو لمفصل.", + "help.construct.axis.normal_to_face": "ينشئ محورًا إنشائيًا يمر بنقطة ويكون عموديًا على وجه. استخدمه كمحور يبرز مستقيمًا من السطح.", + "help.construct.axis.through_cylinder": "ينشئ محورًا إنشائيًا عبر مركز وجه أسطوانة أو مخروط أو طارة، أو حافة دائرية. استخدمه لتدوير الميزات أو تكرارها حول قطعة مستديرة.", + "help.construct.axis.two_planes": "ينشئ محورًا إنشائيًا عند التقاء مستويين أو وجهين مسطحين. استخدمه حين يكون المحور المطلوب هو الخط بين وجهين.", + "help.construct.axis.two_points": "ينشئ محورًا إنشائيًا يمر بنقطتين أو رأسين أو نقطتي رسم. استخدمه لمحور يمتد بين موضعين معروفين.", + "help.construct.plane.along_path": "ينشئ مستوى إنشائيًا عموديًا على حافة أو منحنى عند موضع على امتداده. استخدمه لرسم مقطع من أجل كنس أو لمقطع عرضي للمسار.", + "help.construct.plane.at_angle": "ينشئ مستوى إنشائيًا مائلًا بزاوية عن مستوى أو وجه، حول محور أو حافة. استخدمه للرسوم المائلة، مثل حامل مائل.", + "help.construct.plane.midplane": "ينشئ مستوى إنشائيًا في منتصف المسافة بين مستويين أو وجهين مسطحين، أو منصّفًا لهما حيث يلتقيان بزاوية. استخدمه كمستوى تناظر عند عمل مرآة.", + "help.construct.plane.offset": "ينشئ مستوى إنشائيًا موازيًا لمستوى أو وجه مسطح، على مسافة محددة أو عبر نقطة. استخدمه للرسم على ارتفاع محدد فوق وجه.", + "help.construct.plane.perpendicular": "ينشئ مستوى إنشائيًا يحوي خطًا أو حافة ويكون عموديًا على مستوى أو وجه. استخدمه للرسم بشكل رأسي على النموذج على امتداد حافة.", + "help.construct.plane.tangent": "ينشئ مستوى إنشائيًا يمس أسطوانة أو مخروطًا أو كرة أو طارة عند نقطة. استخدمه للرسم على جانب قطعة مستديرة.", + "help.construct.plane.three_points": "ينشئ مستوى إنشائيًا يمر بثلاث نقاط. استخدمه لمستوى تحدده مواضع على النموذج، مثل ثلاث زوايا.", + "help.construct.plane.two_edges": "ينشئ مستوى إنشائيًا يمر بحافتين مستقيمتين أو خطين يقعان في مستوى واحد. استخدمه لمستوى يقطع القطعة، مثل مستوى يمر بحافتين متقابلتين.", + "help.construct.point.along_path": "ينشئ نقطة إنشائية عند موضع على امتداد حافة أو منحنى. استخدمها كمرجع في موضع بين طرفي المسار.", + "help.construct.point.center": "ينشئ نقطة إنشائية في مركز حافة دائرية أو دائرة رسم أو قوس أو كرة أو طارة. استخدمها كمرجع للثقوب والمحاور والمفاصل.", + "help.construct.point.edge_plane": "ينشئ نقطة إنشائية حيث تخترق حافة أو خط مستوى أو وجهًا مسطحًا. استخدمها لتحديد موضع عبور المسار للمستوى.", + "help.construct.point.three_planes": "ينشئ نقطة إنشائية حيث تلتقي ثلاثة مستويات أو أوجه مسطحة. استخدمها مرجعًا لزاوية، حتى حيث لا يوجد رأس في النموذج.", + "help.construct.point.two_edges": "ينشئ نقطة إنشائية حيث تلتقي حافتان أو خطان، أو حيث يكونان أقرب ما يمكن. استخدمها مرجعًا عند زاوية افتراضية.", + "help.construct.point.vertex": "ينشئ نقطة إنشائية على رأس أو نقطة رسم. استخدمها مرجعًا يبقى حتى عند إخفاء الرسم أو الجسم المحيط بها.", + "help.contact.check": "يسرد أزواج القطع في مجموعات التلامس التي تتداخل أجسامها في مواضعها الحالية. استخدمه لاكتشاف التصادمات قبل تحريك المفاصل أو تشغيل دراسة الحركة.", + "help.contact.create": "يجمع قطعتين أو أكثر في مجموعة تلامس تمنع أجسامها من اختراق بعضها أثناء حركة المفاصل. استخدمه للقطع التي ينبغي أن تتوقف عند التلامس، مثل ذراع يصطدم بمصدّه.", + "help.contact.disable_all": "يعطّل التلامس، فلا تعود المفاصل المحرّكة ودراسات الحركة تتوقف عند تلامس القطع. استخدمه لتحريك القطع بحرية مجددًا أو للحفاظ على سرعة استجابة تجميع كبير.", + "help.contact.edit": "يعدّل مجموعة تلامس: قطعها أو اسمها أو حالة تعطيلها المؤقت، ويمكنه حذف المجموعة أيضًا. استخدمه لتحديد القطع التي يُمنع تداخل أجسامها.", + "help.contact.enable_all": "يضع كل قطع التجميع في مجموعة تلامس واحدة، فلا يمكن لأي قطعتين اختراق بعضهما أثناء حركة المفاصل. استخدمه لفحص آلية كاملة بسرعة.", + "help.contact.enable_sets": "يفعّل التلامس لمجموعات التلامس التي عرّفتها، فتتوقف المفاصل المحرّكة ودراسات الحركة حيث كانت قطعها ستخترق بعضها. استخدمه لإعادة تفعيل مجموعاتك بعد تعطيل التلامس.", + "help.contact.list": "يسرد مجموعات التلامس في التصميم والقطع في كل مجموعة وحالة تفعيل التلامس. استخدمه لفحص إعداد التلامس قبل تحريك التجميع.", + "help.document.inspect": "يعرض نظرة عامة على التصميم: مكوّناته وأجسامه ورسومه وميزاته ومعاملاته. استخدمه للتعرف على تصميم لم تبنِه بنفسك.", + "help.document.units": "يغيّر الوحدات التي تُعرض بها الأطوال في التصميم، مثل mm أو البوصة. يحتفظ الشكل الهندسي بحجمه؛ ويتغير العرض والأرقام العادية فقط.", + "help.edit.checkpoint": "يحفظ التصميم والرسم النشط وسجل التراجع كما هي الآن. استخدمه قبل تجربة شيء محفوف بالمخاطر قد ترغب في العودة منه.", + "help.edit.restore_checkpoint": "يعود إلى نقطة حفظ مع سجل التراجع. استخدمه لتجاهل كل ما أُجري منذ نقطة الحفظ بخطوة واحدة.", + "help.explode.create": "ينشئ عرضًا مفككًا يباعد القطع عن مركز التجميع أو ينقلها وفق التحركات التي تحددها. استخدمه لإظهار كيفية تركيب التجميع في التعليمات أو العروض التقديمية.", "help.face.delete": "يحذف الأوجه المحددة ويترك الأوجه المجاورة تسد الفجوة، أو يترك الفتحة مفتوحة كسطح. استخدمه لإزالة حافة مقوّسة أو ثقب أو نتوء يعترض الطريق.", + "help.file.insert_mesh": "يضيف ملف 3MF أو STL أو OBJ أو PLY أو glTF إلى التصميم كأجسام شبكية في مكوّن جديد. استخدمه لوضع نموذج منزّل أو ممسوح ضوئيًا بجانب تصميمك كمرجع.", + "help.file.insert_step": "يضيف ملف STEP أو IGES أو FreeCAD أو Rhino إلى التصميم كأجسام صلبة في مكوّن جديد، وتصبح قطع التجميع مكوّنات متداخلة. استخدمه لاستيراد نماذج من المورّدين أو برامج CAD الأخرى.", + "help.file.save_mesh": "يحفظ الأجسام كشبكة STL أو 3MF أو OBJ بدرجة نعومة الوجوه المنحنية التي تختارها. استخدمه لتجهيز القطع للطباعة ثلاثية الأبعاد أو للبرامج التي تقرأ الشبكات.", + "help.file.send_to_slicer": "يحفظ الأجسام المحددة في ملف 3MF ويفتحه في برنامج التقطيع الذي تختاره. استخدمه للانتقال من النموذج إلى الطباعة بخطوة واحدة.", "help.font_details": "تستخدم الواجهة خطوط egui المدمجة (Ubuntu Light وHack، بموجب رخصة Ubuntu Font ورخصة MIT/Bitstream Vera، وNoto Emoji وخط أيقونات الرموز التعبيرية بموجب OFL وMIT). يستخدم نص الرسم خط Liberation Sans (SIL OFL 1.1). حزمة الخطوط الاختيارية craft-fonts لتطبيقات Crafting (BIZ UDPGothic وShippori Mincho وBIZ UDMincho وNoto Sans CJK SC وNoto Sans Arabic، بموجب SIL OFL 1.1) غير مضمّنة في إصدارات SolidCraft.", "help.fonts": "الخطوط", + "help.frame.member": "يضع مقطعًا، مثل أنبوب أو قطاع ألومنيوم مبثوق، على امتداد خطوط رسم هيكل، بعنصر واحد لكل خط. استخدمه لبناء الهياكل والرفوف والمنشآت الملحومة.", + "help.inspect.accessibility": "يلوّن الأوجه التي تستطيع أداة الوصول إليها من اتجاه واحد، وتلك التي لا تستطيع. استخدمه لفحص قطعة للتشغيل الآلي أو القولبة من ذلك الجانب.", + "help.inspect.center_of_mass": "يعرض مركز كتلة الأجسام، كلٍّ على حدة ومجتمعةً، بكثافة منتظمة. استخدمه لفحص التوازن ومعرفة أين ستميل القطعة.", + "help.inspect.colored_components": "يمنح كل مكوّن لونه المؤقت الخاص دون تغيير المظاهر. استخدمه لتمييز قطع التجميع بنظرة واحدة.", + "help.inspect.compare": "يقارن جسمين ويبيّن المواضع التي يحتوي فيها أحدهما مادة لا يحتويها الآخر، مع الحجم والانحراف. استخدمه لمقارنة قطعة معدّلة أو مستوردة بالأصل.", + "help.inspect.continuity": "يفحص مدى نعومة التقاء الأوجه عند حوافها: فجوات أو انكسارات أو قفزات في الانحناء، ويظلّل الدرزات. استخدمه لإيجاد المواضع التي لا تلتقي فيها الأسطح بنظافة.", + "help.inspect.curvature_comb": "يرسم أمشاطًا على المنحنيات أو الحواف أو الأوجه تُظهر أسنانها مدى شدة انحنائها. استخدمه لكشف المواضع المسطحة والانكسارات وعدم انتظام الانحناء.", + "help.inspect.curvature_map": "يلوّن الأوجه بحسب شدة انحنائها، بالأحمر حيث يصغر نصف القطر. استخدمه لإيجاد المواضع الضيقة وللحكم على نعومة السطح.", + "help.inspect.draft": "يلوّن الأوجه بحسب زاوية الميل بالنسبة لاتجاه السحب، ويحدد ذات الميل غير الكافي. استخدمه لفحص القطعة قبل القولبة أو الصب.", + "help.inspect.environment_map": "يعرض النموذج بلمسة نهائية عاكسة تشبه المرآة. استخدمه للحكم على انسياب الأسطح من طريقة مرور الانعكاسات عليها.", + "help.inspect.interference": "يجد الأجسام المتداخلة ويعرض كل تداخل مع حجمه. استخدمه لفحص التجميع بحثًا عن قطع تتصادم.", + "help.inspect.isocurve": "يرسم المنحنيات المتساوية المعاملات للأوجه، أي الشبكة التي بُني عليها السطح. استخدمه لمعرفة كيف ينساب السطح وأين تتجمع الخطوط.", + "help.inspect.measure": "يقيس الأطوال والمسافات والزوايا والمساحات وأنصاف الأقطار بين ما تحدده، أو حجم الأجسام وكتلتها. استخدمه للتحقق من المقاسات والخلوصات.", + "help.inspect.minimum_radius": "يجد أصغر نصف قطر انحناء للمنحنيات أو الحواف أو الأوجه وموضعه. استخدمه للتأكد من أن أداة أو فوهة تستطيع اتباع الشكل.", + "help.inspect.section": "يقطع العرض على امتداد مستوى ويهشّر الأوجه المقطوعة دون تغيير النموذج. استخدمه للنظر داخل القطع والتجميعات.", + "help.inspect.zebra": "يعرض خطوطًا سوداء وبيضاء منعكسة على أوجه النموذج. استخدمه للحكم على النعومة: الخطوط التي تنقطع أو تنكسر عند حافة تدل على انتقال ضعيف.", + "help.joint.as_built": "يربط قطعتين في موضعيهما الحاليين دون تحريك أي منهما، ويحدد الحركة المسموحة لهما. استخدمه للقطع التي وضعتها بالفعل في مواضعها النهائية.", + "help.joint.cam": "يربط كامة دوّارة بتابع، فينزلق التابع أو يتأرجح عندما يمر محيط الكامة تحته. استخدمه للكامات واللامركزيات وآليات تشغيل الصمامات.", + "help.joint.create": "يربط قطعتين عند أصلي المفصل اللذين تختارهما وينقل القطعة الثانية إلى موضعها، ويحدد نوع المفصل الحركة المسموحة لهما. استخدمه لبناء تجميع تتحرك قطعه مثل الآلية الحقيقية.", + "help.joint.drive": "يضبط زاوية المفصل أو مسافته ويحرّك القطع معه ضمن حدوده، وتتبعها المفاصل المرتبطة والحلقات المغلقة. استخدمه للتحقق من أن الآلية تتحرك كما هو مقصود.", + "help.joint.gear": "يربط مفصلين دوّارين بنسبة تروس، بحيث يؤدي تحريك أحدهما إلى دوران الآخر. استخدمه للتروس المستقيمة والحلقية، مع تحديد النسبة من أنصاف أقطار دوائر الخطوة أو أعداد الأسنان.", + "help.joint.limits": "يضبط الحدين الأدنى والأقصى لدوران المفصل أو انزلاقه، أو يزيل هذين الحدين. استخدمه لإبقاء مفصلة أو منزلق أو ذراع ضمن نطاق حركته الحقيقي.", + "help.joint.motion_link": "يربط مفصلين بنسبة وإزاحة، بحيث يؤدي تحريك أحدهما إلى تحريك الآخر. استخدمه لعلاقة ثابتة بين المفاصل لا تغطيها أدوات ترس أو جريدة مسننة وترس أو لولب.", + "help.joint.origin": "يضع أصل مفصل باسم على قطعة، عند مركز وجه أو نهاية دائرية أو منتصف حافة أو ركن أو نقطة. استخدمه لتجهيز مواضع اتصال المفاصل، فالأصل المختار على وجه يتبعه عند تغيير مقاس القطعة.", + "help.joint.rack_pinion": "يربط ترسًا دوّارًا بجريدة مسننة منزلقة، فتتحرك الجريدة بمقدار طول قوس دائرة الخطوة الذي يدور به الترس. استخدمه لمحركات الجريدة المسننة وأنظمة التوجيه ومنصات الحركة الخطية.", + "help.joint.reach": "يحرّك نقطة على قطعة نحو هدف بتغيير مفاصل سلسلتها ضمن حدودها. استخدمه لضبط وضعية آلية حسب الموضع المطلوب لنهايتها، مثل طرف ذراع روبوت.", + "help.joint.rigid_group": "يثبّت القطع المحددة معًا كما هي لتتحرك كقطعة واحدة. استخدمه للقطع التي تنتمي إلى وحدة واحدة ولا تتحرك بالنسبة إلى بعضها.", + "help.joint.screw": "يربط الدوران والانزلاق بخطوة لولبية، بحيث تتقدم القطعة بمقدار تلك المسافة في كل دورة. استخدمه للوالب القيادة والبراغي والمحاور الملولبة.", + "help.library.apply": "يطبّق مادة أو مظهرًا من ملف مكتبة محلي على أجسام أو أوجه أو مكوّنات. استخدمه لإعادة استخدام مواد حفظتها من تصميم آخر.", + "help.library.export": "يحفظ مواد التصميم ومظاهره في ملف مكتبة. استخدمه لمشاركتها أو إعادة استخدامها في تصميمات أخرى.", + "help.library.inspect": "يفحص ملف مكتبة ويسرد موادّه ومظاهره دون تغيير التصميم. استخدمه لمعرفة ما تحويه المكتبة قبل تطبيقها.", "help.licences": "الرخص", "help.material.assign": "يحدد المادة التي صُنعت منها الأجسام المحددة، مثل الفولاذ أو الألومنيوم أو ABS. استخدمه لتطابق الكتلة والوزن القطعة الحقيقية.", + "help.material.properties.validate": "يفحص بطاقة بيانات مادة بحثًا عن أرقام غير متسقة أو قيم مفقودة. استخدمه قبل الاعتماد على البيانات في حساب الكتلة أو الصلابة.", + "help.mesh.convert": "يعيد بناء جسم شبكي، مثل ملف STL، كجسم صلب قابل للتعديل بأوجه مسطحة وأسطوانية. استخدمه لتعديل القطع الميكانيكية المستوردة كأنها جسم منمذج.", "help.mit": "رخصة MIT", + "help.motion.study": "يضبط قيم المفاصل عند خطوات على خط زمني ويشغّلها كرسوم متحركة. استخدمه لمشاهدة حركة آلية عبر تسلسل وفحص الخلوص أثناء الحركة.", + "help.motion.trace": "يتتبع مسار نقطة على قطعة أثناء تشغيل دراسة الحركة أو تحريك مفصل، ويحفظه كمنحنى رسم ثلاثي الأبعاد عند التأكيد. استخدمه لرؤية مدى وصول آلية أو لبناء أشكال اعتمادًا على مسارها.", "help.notice": "NOTICE", + "help.occurrence.ground": "يثبّت قطعة في موضعها أو يحررها مجددًا، بحيث تحرّك المفاصل القطع الأخرى حولها. استخدمه للإطار أو القاعدة التي يُبنى عليها باقي التجميع.", "help.occurrence.move": "يحرّك جزءًا في التجميع فقط ضمن درجات الحرية التي تسمح بها مفاصله. حدد وجهًا للجزء، ثم اسحب سهمًا أو قوسًا متاحًا.", "help.parameters.change": "يفتح جدول القيم المسماة والتعبيرات التي تتحكم في أبعاد التصميم. استخدمه لتغيير مقاس في مكان واحد فتتبعه كل الميزات التي تستخدمه.", + "help.parameters.export": "يحفظ معاملات التصميم بصيغة CSV أو JSON. استخدمه لتعديلها في جدول بيانات أو نقلها إلى تصميم آخر.", + "help.parameters.graph": "يبيّن أي المعاملات والميزات تستخدم أيًّا من غيرها، وأي حلقات أو أخطاء بينها. استخدمه لمعرفة ما سيتأثر بتغيير معامل.", + "help.parameters.import": "يقرأ المعاملات من CSV أو JSON إلى التصميم، كلها أو لا شيء منها. استخدمه لإحضار قيم من جدول بيانات أو من تصميم آخر.", + "help.parameters.move": "ينقل معاملًا من معاملات المستخدم قبل معامل آخر أو بعده في القائمة. استخدمه لإبقاء المعاملات المترابطة معًا.", + "help.parts.fastener": "يضيف برغيًا أو مسمار ربط أو صامولة أو وردة أو دبوسًا أو محملًا قياسيًا بالمقاس الذي تختاره. عند وضعه على ثقب يستقر فيه بمفصل، فلا تحتاج إلى نمذجة أدوات التثبيت بنفسك.", + "help.parts.insert": "يدرج قطعة قياسية من الفئة وبالمقاس والطول الذي تختاره. عند وضعها على إطار ثقب أو جداره، تستقر على الثقب بمفصل صلب.", + "help.parts.library": "يفتح القطع القياسية: البراغي ومسامير الربط والصواميل والوردات وبراغي التثبيت ودبابيس التمركز والمحامل بالمقاسات القياسية. استخدمه لإضافة قطعة قياسية بدلًا من نمذجتها بنفسك.", + "help.parts.resize": "يغيّر مقاس قطعة قياسية مدرجة أو طولها ويعيد بناءها، وتبقيها مفاصلها مثبتة في مكانها. استخدمه عندما يتبين أن برغيًا أطول من اللازم أو أن ثقبًا يحتاج إلى مقاس آخر.", + "help.plastic.assign_rule": "يعيّن قاعدة بلاستيك لأجسام، فتأخذ ميزاتها البلاستيكية ميلها وخلوصها منها كقيم افتراضية. استخدمه لإعداد قطعة مقولبة مرة واحدة لكل ميزاتها.", + "help.plastic.boss": "يضيف نتوءًا لمسمار على وجه: عمودًا مع ثقب اختياري وميل وتقويس عند الجذر وأضلاع تقوية. استخدمه لنقاط البراغي في الهياكل المقولبة أو المطبوعة.", + "help.plastic.lip": "يضيف شفة أو أخدودًا مطابقًا على طول حافة جسم مفرَّغ. استخدمه ليتمركز نصفا الهيكل أحدهما على الآخر ويُغلقا بإحكام.", + "help.plastic.manage_rules": "ينشئ قواعد البلاستيك ويعدّلها: المادة وسماكة الجدار وأنصاف الأقطار والخلوص والميل. استخدمها للحفاظ على اتساق ميزات القطعة المقولبة.", + "help.plastic.rest": "يضيف مسندًا على وجه: وسادة مستديرة أو مستطيلة، مصمتة أو مجوفة، مع ميل. استخدمه كدعامة أو مُباعِد في هيكل مقولب.", + "help.plastic.snap_fit": "يضيف خطاف وصلة كبس على وجه، بطوله وسماكته ونتوء التثبيت. استخدمه لتثبيت القطع معًا بلا براغي.", "help.pointcloud.insert": "أدرج مسحًا بصيغة PLY أو XYZ أو PTS كسحابة نقاط بجانب النموذج. استخدمه كمرجع عند النمذجة انطلاقًا من بيانات القياس.", + "help.sheet.convert": "يحوّل لوحًا أو جسمًا مفرَّغًا بسماكة منتظمة إلى جسم صفائح معدنية، مع ثنيات عند الحواف التي تحددها. استخدمه لجعل قطعة موجودة قابلة للفرد.", + "help.sheet.export_bend_table": "يحفظ جدول الثني لجسم صفائح معدنية بصيغة CSV أو JSON: الزوايا وأنصاف الأقطار ومواضع الثني في النمط المسطح. استخدمه لماكينة الثني أو الورشة.", + "help.sheet.export_dxf": "يحفظ النمط المسطح بصيغة DXF، مع الحدود وخطوط الثني وملاحظات الثني الاختيارية. استخدمه للقطع بالليزر أو البلازما أو نفاث الماء.", + "help.sheet.flange": "يضيف حافة مثنية: لوحًا أساسيًا من رسم، أو جدارًا على امتداد محيط رسم مفتوح، أو جدارًا مثنيًا على حواف الصفيحة. استخدمه لبناء قطع الصفائح المعدنية.", + "help.sheet.flat_pattern": "ينشئ النمط المسطح لجسم صفائح معدنية، مع حجمه وفتحاته وخطوط الثني. استخدمه لفحص الشريحة الخام قبل قصها.", + "help.sheet.fold": "يثني صفيحة على امتداد خط رسم بزاوية ونصف قطر محددين. استخدمه لطي صفيحة مسطحة بعد عمل القطوع فيها.", + "help.sheet.hem": "يطوي حافة صفيحة على نفسها. استخدمه لحواف آمنة وصلبة بلا نتوءات حادة.", + "help.sheet.manage_rules": "ينشئ قواعد الصفائح المعدنية ويعدّلها: السماكة ونصف قطر الثني ومعامل K والتفريغات والفجوات. استخدمها لمطابقة مادة ورشتك وأدواتها.", + "help.sheet.path_flange": "يضيف سلسلة من الحواف المثنية على امتداد حافة صفيحة، متتبعًا سلسلة مفتوحة من خطوط الرسم. استخدمها لمقاطع مثل شكل U أو Z بخطوة واحدة.", + "help.sheet.perforate": "يضيف حقلًا من الثقوب على كامل الصفيحة المسطحة، بنمط متدرج أو مربع. استخدمه للتهوية والشبكات والألواح الأخف.", + "help.sheet.refold": "يعيد طي جسم صفائح معدنية مفرود. استخدمه بعد إضافة ميزات إلى الصفيحة المسطحة.", + "help.sheet.unfold": "يفرد جميع ثنيات جسم صفائح معدنية. استخدمه لإضافة ثقوب أو قطوع يجب أن تعبر ثنية، ثم استخدم إعادة الطي.", + "help.sketch.arc.center_point": "يرسم قوسًا دائريًا حول نقطة مركز، من نقطة بداية إلى نقطة نهاية. استخدمه عندما يكون مركز القوس معروفًا، مثل نصف قطر حول ثقب أو محور.", + "help.sketch.arc.tangent": "يرسم قوسًا يبدأ من نهاية خط أو قوس ويكمل مماسًا له. استخدمه لمتابعة السلسلة بانحناء ناعم بدل زاوية حادة.", + "help.sketch.arc.three_point": "يرسم قوسًا من نقطة بداية إلى نقطة نهاية مارًا بنقطة ثالثة. استخدمه عندما يجب أن يمر القوس بنقاط محددة لا حول مركز معروف.", + "help.sketch.arc3d": "يرسم قوسًا دائريًا دقيقًا في الفراغ عبر ثلاث نقاط، مع تفعيل الرسم ثلاثي الأبعاد. استخدمه لانحناءات المسارات وقضبان التوجيه الخارجة عن مستوى الرسم.", + "help.sketch.auto_constrain": "يضيف القيود التي يكاد الرسم يحققها أصلًا، مثل الأفقي أو المتوازي أو المتطابق، ثم يضيف الأبعاد حتى يصبح محددًا بالكامل. استخدمه لتثبيت رسم تقريبي بخطوة واحدة.", + "help.sketch.auto_constrain_from_datum": "يعمل مثل «تقييد تلقائي»، لكنه يقيس المواضع من نقطة مرجعية تختارها بدل نقطة الأصل. استخدمه عندما تُحدَّد أبعاد القطعة انطلاقًا من نقطة مرجعية واحدة، كركن أو ثقب.", + "help.sketch.blend_curve": "يصل نهايتي منحنيين بمنحنى شريحي ناعم مماس لكليهما، أو مستمر الانحناء أيضًا إن اخترت. استخدمه لسد الفجوة بين منحنيين دون التواء.", + "help.sketch.break": "يقسم المنحنى إلى أجزاء عند تقاطع منحنيات أخرى معه، أو عند نقاط تضعها عليه. استخدمه عندما يحتاج جزء من المنحنى إلى قيده الخاص أو إلى حذفه وحده.", + "help.sketch.cancel": "يغادر الرسم ويتجاهل كل ما تغيّر منذ إنشائه أو فتحه للتعديل. ويمكن استعادة التغييرات بأمر «تراجع».", + "help.sketch.centerline": "يبدّل الخطوط بين خطوط عادية وخطوط المركز، وهي محاور لا تدخل في المقاطع. استخدم خط المركز محورًا لأمر «دوران» أو «مرآة» أو لقيد التناظر.", + "help.sketch.chamfer.distance_angle": "يشطف الزاوية بين خطين بمسافة على طول الخط الأول وبزاوية. استخدمه عندما يعطي المخطط الشطف بطول وزاوية، مثل 2 mm × 30°.", + "help.sketch.chamfer.equal_distance": "يشطف الزاوية بين خطين بالمسافة نفسها على طول كليهما. استخدمه للشطف المعتاد بزاوية 45° على المحيط.", + "help.sketch.chamfer.two_distance": "يشطف الزاوية بين خطين بمسافة مستقلة على كل خط. استخدمه للشطف بزاوية غير 45°، المعطى بطولين.", + "help.sketch.circle.center": "يرسم دائرة من مركزها وقطرها. استخدمه للثقوب والنتوءات والميزات المستديرة الأخرى المعروف مركزها.", + "help.sketch.circle.three_point": "يرسم دائرة تمر بثلاث نقاط. استخدمه عندما يجب أن تمر الدائرة بنقاط محددة، مثلًا من هندسة موجودة.", + "help.sketch.circle.three_tangent": "يرسم دائرة تمس ثلاثة خطوط أو دوائر أو أقواس. استخدمه لإدخال دائرة في الفراغ بين ثلاثة منحنيات، مثل الدائرة المحاطة بمثلث.", + "help.sketch.circle.two_point": "يرسم دائرة من طرفي قطرها. استخدمه عندما تكون نقطتا حافة الدائرة معروفتين لا مركزها.", + "help.sketch.circle.two_tangent": "يرسم دائرة بحجم محدد تمس خطين أو دائرتين أو قوسين. استخدمه لتقويس ثابت نصف القطر بين منحنيين.", + "help.sketch.conic": "يرسم منحنى مخروطيًا بين نقطتي نهاية، يتشكل بنقطة قمة وقيمة rho. استخدمه لانتقالات ناعمة تضبط امتلاءها من المسطح إلى ما يقارب زاوية حادة.", + "help.sketch.constrainer": "يطبق القيد الذي يكاد منحنى أو منحنيان محددان يحققانه، مثل الأفقي أو التوازي أو التماس أو التساوي. استخدمه لتهذيب رسم يدوي علاقة بعد أخرى.", + "help.sketch.constraint.coincident": "يجعل نقطة تقع على نقطة أخرى أو على منحنى أو على محور الرسم. استخدمه لسد الفجوات في المقطع أو لوضع نقطة على خط.", + "help.sketch.constraint.collinear": "يجعل خطين يقعان على خط مستقيم واحد. استخدمه لمحاذاة الحواف المتقطعة، مثل جانبي حز.", + "help.sketch.constraint.concentric": "يعطي دائرتين أو قوسين المركز نفسه. استخدمه للحلقات والتجاويف المتدرجة والأعمدة ذات الأكتاف.", + "help.sketch.constraint.curvature": "يجعل منحنيين يلتقيان عند نقطة نهاية يتابعان بالانحناء نفسه (G2). استخدمه حيث لا يكفي الوصل المماسي نعومة، مثلًا على الأسطح الظاهرة.", + "help.sketch.constraint.equal": "يجعل الخطوط بالطول نفسه، أو الدوائر والأقواس بنصف القطر نفسه. استخدمه ليتحكم بُعد واحد في عدة ميزات متماثلة.", + "help.sketch.constraint.fix": "يثبّت النقاط أو المنحنيات في مكانها، أو يفك تثبيتها. استخدمه للهندسة التي يجب ألا تتحرك عند سحب الرسم أو حله.", + "help.sketch.constraint.horizontal_vertical": "يجعل خطًا، أو الخط بين نقطتين، أفقيًا أو رأسيًا، أيهما أقرب. استخدمه لمحاذاة المحيط مع محاور الرسم.", + "help.sketch.constraint.midpoint": "يضع نقطة في منتصف خط، أو يجعل منتصفي خطين يلتقيان. استخدمه لتوسيط الهندسة على حافة.", + "help.sketch.constraint.parallel": "يجعل خطين متوازيين. استخدمه للأضلاع التي يجب أن تبقى متوازية عند تغيير الأبعاد الأخرى.", + "help.sketch.constraint.perpendicular": "يجعل خطين يلتقيان بزاوية قائمة. استخدمه للزوايا القائمة بين خطوط ليست على محاور الرسم.", + "help.sketch.constraint.polygon": "يجعل سلسلة مغلقة من الخطوط مضلعًا منتظمًا: أضلاع متساوية وكل زاوية على دائرة واحدة. استخدمه لجعل مضلع مرسوم يدويًا منتظمًا والحفاظ على ذلك.", + "help.sketch.constraint.symmetry": "يجعل نقطتين أو منحنيين متناظرين حول خط أو نقطة. استخدمه للحفاظ على تناظر المقطع عند تغيير أحد جانبيه.", + "help.sketch.constraint.tangent": "يجعل منحنيين يتماسان مماسيًا، مثل خط وقوس. استخدمه لوصلات ناعمة بلا التواء.", + "help.sketch.construction": "يبدّل المنحنيات بين الهندسة العادية والهندسة الإنشائية التي تتجاهلها المقاطع. استخدم المنحنيات الإنشائية مساعدات للتخطيط والمراجع والأبعاد.", "help.sketch.construction_line": "أنشئ خطًا إنشائيًا لا نهائيًا يمر بنقطتين. استخدمه لتخطيط الرسم دون تقسيم مقاطعه.", "help.sketch.construction_line.angle": "أنشئ خطًا إنشائيًا لا نهائيًا بزاوية من محور X للرسم. اختر النقطة ثم اكتب الزاوية، أو اختر اتجاهه.", "help.sketch.construction_line.bisector": "اختر خطين مستقيمين متقاطعين في الرسم، ثم اختر أحد منصفي الزاوية. يتبع الناتج الخطين اللذين أُنشئ منهما.", @@ -1415,12 +1571,71 @@ "help.sketch.construction_line.tangent_external": "اختر دائرة أو قوسًا دائريًا، ثم نقطة خارجية وحل المماس. تعرض المعاينة نقاط التماس الواقعة على القوس المرسوم فقط.", "help.sketch.construction_line.tangent_two": "اختر دائرتين أو قوسين دائريين ثم اختر مماسًا مشتركًا من المعاينة. يجب أن تقع نقطتا التماس على المنحنيين المرسومين.", "help.sketch.construction_line.vertical": "أنشئ خطًا إنشائيًا رأسيًا لا نهائيًا يمر بنقطة. يحافظ القيد الرأسي على الاتجاه بعد التعديلات.", + "help.sketch.copy": "ينسخ منحنيات الرسم ونقاطه المحددة مع القيود والأبعاد بينها. الصقها في هذا الرسم أو في رسم آخر.", + "help.sketch.create": "يبدأ رسمًا ثنائي الأبعاد جديدًا على مستوى أو على وجه مسطح من النموذج. يبدأ كل بثق ودوران وكنس برسم.", + "help.sketch.cut": "ينسخ هندسة الرسم المحددة ويحذفها من الرسم بخطوة تراجع واحدة. الصقها في مكان آخر من هذا الرسم أو في رسم آخر.", + "help.sketch.dimension": "يضيف بُعدًا حاكمًا: طولًا أو مسافة أو زاوية أو نصف قطر أو قطرًا بحسب ما تختاره. اكتب قيمة أو اسم معامل فيتبعها الرسم.", + "help.sketch.edit": "يفتح رسمًا موجودًا للتعديل. تتحدّث الميزات المبنية عليه عند إنهاء الرسم.", + "help.sketch.ellipse": "يرسم قطعًا ناقصًا من مركزه ونهاية محوره الأكبر ونقطة على جانبه الأصغر. استخدمه للفتحات البيضاوية والكامات وغيرها من الأشكال المستديرة المتطاولة.", "help.sketch.equation_curve": "يرسم منحنى من صيغ على مدى: x(t) و y(t)، أو y = f(x). يمكن أن تستخدم الصيغ المعلمات فيتبعها المنحنى: استخدمه لموجة جيبية أو لولب أو منحنى انفلات لسن ترس.", + "help.sketch.extend": "يمدد خطًا أو قوسًا حتى أقرب منحنى في طريقه، أو ينمّي منحنيين حتى يلتقيا. استخدمه لسد الفجوات في المقطع بعد القص أو التعديل.", + "help.sketch.fillet": "يقوّس الزاوية التي يلتقي فيها خطان أو قوسان بقوس ذي نصف قطر محدد. الزوايا المقوّسة معًا تشترك في نصف قطر واحد.", + "help.sketch.finish": "يغادر الرسم ويعود إلى النموذج. تصبح مقاطعه المغلقة جاهزة للبثق والدوران وغيرهما من الميزات.", + "help.sketch.finish_auto_constrain": "يشغّل «تقييد تلقائي» على الرسم ثم ينهيه. استخدمه لترك الرسم محددًا بالكامل بخطوة واحدة.", + "help.sketch.fit_curves_to_section": "يقطع شبكة أو جسمًا صلبًا بمستوى الرسم ويلائم خطوطًا وأقواسًا مع المقطع. استخدمه لإعادة بناء قطعة ممسوحة أو مستوردة كرسم قابل للتعديل.", + "help.sketch.include_3d": "يجلب حواف النموذج ورؤوسه ومنحنيات رسوم أخرى إلى هذا الرسم كمراجع مرتبطة، ويسطّح ما هو خارج مستواه عليه. استخدمه للبناء على هندسة لا تقع في مستوى الرسم.", + "help.sketch.infinite": "يبدّل الخطوط بين خطوط عادية وخطوط إنشائية لا نهائية تمتد عبر العرض كله. استخدمها مراجع للتخطيط لا تقصر عن الحاجة أبدًا.", + "help.sketch.insert_dxf": "يقرأ منحنيات مخطط DXF إلى رسم بالمقياس والدوران والموضع الذي تحدده. استخدمه للبناء على مخطط ثنائي الأبعاد من برنامج آخر، مثل محيط للقطع بالليزر.", + "help.sketch.insert_svg": "يقرأ مسارات مخطط SVG إلى رسم بالمقياس والدوران والموضع الذي تحدده. استخدمه لتحويل شعار أو محيط متجهي إلى منحنيات يمكنك بثقها.", "help.sketch.inspect": "يفتح تقريرًا عن الرسم المفتوح: عدد درجات الحرية المتبقية وهل هو مقيد بالكامل. استخدمه لمعرفة ما لا يزال يتحرك قبل إنهاء الرسم.", + "help.sketch.intersect": "يضيف المنحنيات أو النقاط التي تقطع فيها أجسام أو أوجه أو حواف أو رسوم أخرى مستوى الرسم. تبقى مرتبطة وتتبع النموذج عندما يتغير.", + "help.sketch.intersection_curve": "ينشئ منحنيات ثلاثية الأبعاد حيث يلتقي جسمان أو وجهان، مرتبطة بهما. استخدمها مسارات أو قضبان توجيه تتبع النموذج.", + "help.sketch.isoparametric_curve": "ينشئ منحنى ثلاثي الأبعاد على اتجاه U أو V للوجه عبر نقطة تختارها، مرتبطًا بالوجه. استخدمه مسارًا أو خط تقسيم يتبع السطح.", + "help.sketch.line": "يرسم خطوطًا متصلة نقطة بنقطة، كسلسلة مفتوحة أو محيط مغلق. هو الأداة الأساسية لمعظم المقاطع.", + "help.sketch.line.midpoint": "يرسم خطًا من منتصفه إلى أحد طرفيه، ويعكس الطرف الآخر. استخدمه للخطوط المتمركزة على نقطة، مثل حافة متناظرة.", + "help.sketch.line3d": "يرسم خطوطًا مستقيمة متصلة بين نقاط في الفراغ، مع تفعيل الرسم ثلاثي الأبعاد. استخدمه للمسارات والإطارات الخارجة عن مستوى الرسم.", + "help.sketch.mirror": "يعكس منحنيات الرسم ونقاطه حول خط أو محور ويضيف قيود تناظر. استخدمه لرسم نصف مقطع متناظر ثم إكماله.", + "help.sketch.move": "يحرّك منحنيات الرسم ونقاطه أو يدوّرها، أو يفعل ذلك بنسخة منها. استخدمه لإعادة وضع الهندسة مع الحفاظ على شكلها وقيودها.", + "help.sketch.offset": "ينشئ نسخة من سلسلة منحنيات على مسافة محددة، من جانب واحد أو من الجانبين. استخدمه للجدران والمحيطات والخلوصات؛ وتبقى المسافة بُعدًا قابلًا للتعديل.", + "help.sketch.paste": "يلصق في الرسم النشط هندسة رسم نسختها أو قصصتها. ضعها عند نقطة أو أزحها بمقدار.", + "help.sketch.pattern.circular": "ينسخ هندسة الرسم حول نقطة مركز، على دائرة كاملة أو زاوية محددة. استخدمه لدوائر البراغي والأذرع والأسنان.", + "help.sketch.pattern.rectangular": "ينسخ هندسة الرسم في صفوف وأعمدة بمسافات محددة. استخدمه لشبكات الثقوب والشقوق وفتحات التهوية.", + "help.sketch.point": "يضع نقطة رسم. استخدمها مرجعًا للأبعاد والقيود ومراكز الثقوب أو الميزات اللاحقة.", + "help.sketch.point3d": "يضع نقطة في الفراغ، مع تفعيل الرسم ثلاثي الأبعاد. استخدمها مرجعًا للمنحنيات والمسارات الثلاثية الأبعاد.", + "help.sketch.polygon.circumscribed": "يرسم مضلعًا منتظمًا حول دائرة، بحجم يُحدد من مركزها إلى منتصف ضلع. استخدمه عندما يكون العرض بين الضلعين المتقابلين معروفًا، كما في صامولة سداسية.", + "help.sketch.polygon.edge": "يرسم مضلعًا منتظمًا من طرفي ضلع واحد. استخدمه عندما يكون طول الضلع وموضعه معروفين.", + "help.sketch.polygon.inscribed": "يرسم مضلعًا منتظمًا داخل دائرة، بحجم يُحدد من مركزها إلى أحد رؤوسه. استخدمه عندما يكون البعد بين الرأسين المتقابلين معروفًا.", + "help.sketch.project": "يجلب حواف النموذج وأوجهه ورؤوسه ومنحنيات رسوم أخرى إلى هذا الرسم، مسقطة على مستواه. تبقى مرتبطة وتتبع النموذج عندما يتغير.", + "help.sketch.project_to_surface": "يسقط منحنيات رسوم أخرى على وجه في اتجاه العمودي على مستوى هذا الرسم، كمنحنيات ثلاثية الأبعاد مرتبطة. استخدمه لوضع شعار أو خط تقسيم أو مسار على سطح منحنٍ.", + "help.sketch.rectangle.center": "يرسم مستطيلًا من مركزه وأحد أركانه. استخدمه للمحيطات المتمركزة على نقطة، مثل نقطة الأصل.", + "help.sketch.rectangle.rounded": "يرسم مستطيلًا بأركان أربعة مدوّرة بنصف قطر واحد. استخدمه للألواح والأغطية والفتحات ذات الأركان المدوّرة.", + "help.sketch.rectangle.three_point": "يرسم مستطيلًا من حافة واحدة ونقطة على الجانب المقابل. استخدمه للمستطيلات المائلة عن محاور الرسم.", + "help.sketch.rectangle.two_point": "يرسم مستطيلًا من ركنين متقابلين. استخدمه لمحيطات الألواح والكتل والجيوب.", + "help.sketch.scale": "يغيّر مقياس منحنيات الرسم ونقاطه بمعامل حول نقطة أساس. استخدمه لتغيير حجم جزء من الرسم دون إعادة رسمه.", + "help.sketch.slot.arc_center": "يرسم شقًا منحنيًا بعرض محدد على امتداد قوس يُحدد بمركزه وبدايته ونهايته. استخدمه لشقوق الضبط حول محور.", + "help.sketch.slot.arc_three_point": "يرسم شقًا منحنيًا بعرض محدد على امتداد قوس يمر بثلاث نقاط. استخدمه عندما يجب أن يمر مسار الشق بنقاط محددة.", + "help.sketch.slot.center_point": "يرسم شقًا مستقيمًا بعرض محدد من مركزه إلى مركز قوس أحد طرفيه. استخدمه للشقوق المتمركزة على نقطة.", + "help.sketch.slot.center_to_center": "يرسم شقًا مستقيمًا بعرض محدد بين مركزي قوسي طرفيه. استخدمه للشقوق المقيّسة من مركز إلى مركز، مثل شقوق ضبط البراغي.", + "help.sketch.slot.overall": "يرسم شقًا مستقيمًا بعرض محدد من طرفه الخارجي إلى الطرف الآخر. استخدمه عندما يكون الطول الكلي للشق هو المهم.", + "help.sketch.spline.control_point": "يرسم منحنى شريحيًا تكعيبيًا يتشكل بنقاط تحكم لا يمر بها. استخدمه للمنحنيات الناعمة المنسابة التي تشكلها بتحريك النقاط.", + "help.sketch.spline.control_point_5": "يرسم منحنى شريحيًا من الدرجة 5 يتشكل بنقاط تحكم. استخدمه حيث يجب أن تلتقي المنحنيات بانحناء أنعم، مثلًا على الأسطح الظاهرة ذات التصميم الانسيابي.", + "help.sketch.spline.fit_point": "يرسم منحنى شريحيًا ناعمًا يمر بكل نقطة تضعها، مفتوحًا أو مغلقًا. استخدمه لتتبع شكل عبر نقاط معروفة.", + "help.sketch.spline3d": "يرسم منحنى ناعمًا في الفراغ عبر نقاط، مع تفعيل الرسم ثلاثي الأبعاد. استخدمه لمسارات الكنس ومسارات الكابلات الخارجة عن مستوى الرسم.", + "help.sketch.spun_profile": "ينشئ في مستوى الرسم المحيط الذي يمسحه جسم عند دورانه حول محور. استخدمه للحصول على صورة ظلية لقطعة مشغولة بالخراطة من أجل أمر «دوران» أو مخطط.", + "help.sketch.text": "يكتب نصًا في الرسم بخط وارتفاع وزاوية تختارها، ويمكن لكلماته أن تتبع المعلمات. ابثقه أو حوّله إلى نقش بارز للملصقات والكتابة.", + "help.sketch.transform": "يغيّر مقياس هندسة الرسم ويدوّرها ويحرّكها حول نقطة مركز بخطوة واحدة. استخدمه لإعادة وضع تحديد وتغيير حجمه معًا.", + "help.sketch.trim": "يزيل الجزء من المنحنى بين أقرب نقطتي تقاطع حول موضع نقرتك. استخدمه لتنظيف الخطوط المتداخلة وتحويلها إلى مقطع مغلق.", "help.solid.align": "يحرّك الأجسام المحددة بحيث تلتقي نقطة أو وجه أو مستوى منها بنقطة أو وجه أو مستوى في مكان آخر. استخدمه لوضع قطعة ملاصقة لأخرى دون قياس المسافة.", + "help.solid.boundary_fill": "ينشئ أجسامًا صلبة من المناطق التي تحيط بها أجسام أدوات متداخلة، مع تعبئة المناطق التي تختارها فقط. استخدمه لبناء شكل من الحيز الذي تحدّه عدة أجسام معًا.", + "help.solid.bounding_solid": "ينشئ صندوقًا يحيط بالأجسام المحددة، مع هامش اختياري. استخدمه لمعرفة الأبعاد الكلية لقطعة أو كخامة أولية تُشغّل منها.", + "help.solid.box": "ينشئ صندوقًا بطول وعرض وارتفاع، انطلاقًا من نقطة ركن أو متمركزًا حول نقطة. استخدمه لإنشاء كتلة سريعة أو شكل مؤقت أو جسم تُقطع فيه ميزات.", "help.solid.chamfer": "يقطع الحواف المحددة بشطفة مستوية بالمسافة التي تحددها. استخدمه لكسر الحواف الحادة أو لتسهيل إدخال مسمار أو برغي.", + "help.solid.coil": "ينشئ جسمًا حلزونيًا مثل النابض من قطر وعدد لفات وارتفاع أو خطوة، وبمقطع دائري أو مربع. استخدمه للنوابض والملفات والنتوءات الحلزونية.", "help.solid.combine": "يضم الأجسام، أو يقتطعها من جسم هدف، أو يُبقي الحجم المشترك بينها فقط، ويمكنه الاحتفاظ بأجسام الأداة. استخدمه لدمج القطع، أو لقطع شكل بشكل آخر، أو لمعرفة موضع تداخل جسمين.", + "help.solid.cone": "ينشئ مخروطًا من دائرة قاعدة وارتفاع، أو مخروطًا ناقصًا عند تحديد نصف قطر للقمة أيضًا. استخدمه للأشكال المستدقة والقمع والتجاويف المخروطية والرؤوس المدببة.", + "help.solid.cylinder": "ينشئ أسطوانة بنصف قطر أو قطر وارتفاع، قائمة عند نقطة على امتداد محور. استخدمه للأعمدة والدبابيس والنتوءات الدائرية والخامات الأسطوانية.", "help.solid.draft": "يميل الأوجه المحددة بالزاوية التي تحددها، مقيسةً من مستوى محايد. استخدمه في القطع المقولبة أو المصبوبة كي تخرج من القالب بسهولة.", + "help.solid.emboss": "يبرز مقاطع رسم مثل نص أو شعار من وجه، أو يغورها فيه. استخدمه للكتابات والتسميات والعلامات على الوجوه المستوية أو المستديرة.", "help.solid.extrude": "يدفع مقطع رسم إلى ثلاثي الأبعاد على مسافة محددة، بإضافة مادة أو إزالتها. استخدمه لتحويل مخطط مسطح إلى كتلة أو نتوء أو تجويف.", "help.solid.fillet": "يقوّس الحواف المحددة بانحناء دائري ناعم بنصف القطر الذي تحدده. استخدمه لتنعيم الحواف الحادة من أجل المتانة أو الراحة أو المظهر.", "help.solid.hole": "يحفر ثقبًا دائريًا بالقطر الذي تحدده عند نقطة مختارة على وجه. استخدمه لفتحات البراغي والمسامير والفتحات الدائرية البسيطة الأخرى.", @@ -1429,20 +1644,60 @@ "help.solid.move": "يحرّك الأجسام المحددة أو يدوّرها، أو نسخة منها، بالمسافة والزاوية اللتين تحددهما. استخدمه لتغيير موضع جسم أو لإنشاء جسم ثانٍ في مكان آخر.", "help.solid.offset_face": "يدفع الأوجه المحددة إلى الداخل أو الخارج بالمسافة التي تحددها، وتتبعها الأوجه المحيطة. استخدمه لزيادة سماكة جدار أو عمق جيب أو ارتفاع نتوء دون تعديل الرسم.", "help.solid.pattern.circular": "يكرر العناصر المحددة حول محور، بالعدد والزاوية الكلية اللذين تحددهما. استخدمه لدوائر البراغي والأذرع والزعانف والأسنان.", + "help.solid.pattern.path": "ينسخ ميزات أو أجسامًا على امتداد مسار رسم، بالعدد والتباعد. استخدمه لثقوب على حافة منحنية أو قطع على سكة.", "help.solid.pattern.rectangular": "يكرر العناصر المحددة في اتجاه واحد أو اتجاهين بالعدد والتباعد اللذين تحددهما. استخدمه لصفوف الثقوب والأضلاع والمسامير والأرجل وشبكاتها.", + "help.solid.pipe": "ينشئ أنبوبًا دائريًا على مسار من منحنيات الرسم، مصمتًا أو مجوفًا بسماكة جدار محددة. استخدمه للأنابيب والأسلاك والكابلات والقضبان.", "help.solid.press_pull": "يضيف المادة أو يزيلها حين تسحب وجهًا، أو يقوّس الحواف حين تسحبها. استخدمه لإجراء تعديلات سريعة على النموذج مباشرة.", "help.solid.remove": "يُخرج الأجسام المحددة من التصميم بدءًا من هذه النقطة في السجل، وتحتفظ بها الخطوات السابقة. استخدمه للتخلص من جسم مساعد، مثل أداة قطع لم تعد تحتاجها.", "help.solid.replace_face": "ينقل الأوجه المستوية المحددة إلى مستوى أو إلى وجه موازٍ، فيطول الجسم أو يقصر ليبلغه. استخدمه لجعل وجه مستويًا مع قطعة أخرى أو مع مستوى إنشائي.", "help.solid.revolve": "يدير مقطع رسم حول محور لصنع جسم دائري. استخدمه للأعمدة والمقابض والأوعية وأي قطعة دائرية حول خط مركزي.", + "help.solid.rib": "يضيف ضلع تقوية رقيقًا على امتداد منحنى رسم مفتوح، ممتدًا حتى الوجوه التالية للجسم. استخدمه لتقوية الجدران والزوايا، مثلًا في القطع المقولبة أو المطبوعة.", "help.solid.scale": "يكبّر الأجسام المحددة أو يصغّرها بمعامل، متساوٍ في كل الاتجاهات أو مختلف لكل محور. استخدمه لتغيير حجم شكل مستورد أو لحساب الانكماش.", "help.solid.shell": "يفرّغ الجسم الصلب تاركًا جدرانًا بالسماكة التي تحددها، ويمكنه فتح الأوجه التي تختارها. استخدمه للصناديق والأغطية والقطع الأخرى رقيقة الجدران.", + "help.solid.sphere": "ينشئ كرة بنصف قطر أو قطر حول نقطة مركز. استخدمه للكرات والمقابض والنهايات المستديرة.", "help.solid.split_body": "يقسم جسمًا إلى أجسام منفصلة بمستوى أو بوجه جسم آخر أو بخط من رسم. استخدمه لصنع قطع تُطبع أو تُشغَّل أو تُلوَّن كلٌّ على حدة.", "help.solid.split_face": "يقسم الأوجه المحددة على طول مستوى أو وجه جسم آخر أو منحنيات رسم، دون قطع الجسم. استخدمه لإعطاء جزء من وجه ميلًا أو لونًا أو إزاحة خاصة به.", "help.solid.sweep": "يسحب مقطع رسم على طول مسار لصنع جسم صلب. استخدمه للأنابيب والقضبان والمقابض وأي قطعة ثابتة المقطع تتبع منحنى.", + "help.solid.thread": "يضيف قلاوظًا متريًا بمعيار ISO إلى وجه أسطواني، إما كتمثيل شكلي أو محفورًا في الجسم. استخدمه للبراغي والثقوب الملولبة والقطع اللولبية المطبوعة.", + "help.solid.torus": "ينشئ حلقة بنصف قطر رئيسي حول محورها ونصف قطر ثانوي لمقطعها الدائري. استخدمه لحلقات منع التسرب والمقابض والنتوءات الحلقية المستديرة.", + "help.solid.web": "يضيف جسر تقوية رقيقًا على امتداد كل منحنى رسم مفتوح، يمتد كل منها حتى الوجوه التالية للجسم. استخدمه لإنشاء شبكة من الجدران الداعمة داخل الأغلفة والصواني.", + "help.surface.blend": "ينشئ سطحًا يصل بين حواف جسمين، مماسًّا أو متصل الانحناء مع كليهما. استخدمه لوصل سطحين بنعومة عبر فجوة.", + "help.surface.boundary": "ينشئ سطحًا يملأ حلقة من منحنيين إلى أربعة منحنيات رسم، أو شبكة من المنحنيات. استخدمه للوحات حرة الشكل تحددها حوافها.", + "help.surface.extend": "يمدّد سطحًا إلى ما بعد حوافه، بمسافة أو حتى مستوى أو وجه. استخدمه لكي تتداخل الأسطح قبل القص أو الخياطة.", + "help.surface.extrude": "يدفع منحنيات أو مقاطع رسم مسافة محددة لتكوين سطح، جدران فقط بلا أغطية. استخدمه للجدران الرقيقة وأدوات القطع أو التقسيم.", + "help.surface.fair": "ينعّم داخل الأوجه حرة الشكل مع بقاء حوافها ثابتة. استخدمه لتسوية النتوءات والانعكاسات المتموجة في السطح.", + "help.surface.loft": "ينشئ سطحًا يمر عبر مقطعين أو أكثر بالترتيب، بلا أغطية. استخدمه للأغلفة والانتقالات بين أشكال مختلفة.", + "help.surface.match": "يعيد بناء حافة سطح لتلتقي بحافة جسم آخر، بمماسية أو انحناء متطابق. استخدمه لإغلاق درزة بنعومة بين سطحين.", + "help.surface.offset": "ينشئ نسخة من الأوجه على مسافة على امتداد اتجاهاتها العمودية، كجسم سطحي جديد. استخدمه للأغلفة أو الخلوصات أو الجانب الآخر من جدار.", + "help.surface.patch": "يملأ منطقة رسم أو حلقة مغلقة من الحواف بوجه، مسطح أو منحنٍ بنعومة. استخدمه لسدّ الثقوب في جسم سطحي.", + "help.surface.reverse_normal": "يقلب أوجه جسم سطحي بحيث يتجه سطحها الخارجي إلى الجهة الأخرى. استخدمه قبل التثخين أو الخياطة حين تتجه الأوجه إلى الداخل.", + "help.surface.revolve": "يدير منحنيات أو مقاطع رسم حول محور ليكوّن سطحًا، بلا أغطية. استخدمه للأغلفة المستديرة مثل أباجورات المصابيح والأوعية والفوهات.", + "help.surface.ruled": "ينشئ شريطًا من خطوط مستقيمة ممتدة من سلسلة حواف، باتجاه عمودي الوجه أو مماسه أو بزاوية. استخدمه لجدران الميل وأسطح الامتداد وأسطح الفصل.", + "help.surface.stitch": "يخيط أجسامًا سطحية معًا على امتداد حوافها المشتركة، وتصبح جسمًا صلبًا إذا أغلقت. استخدمه لتحويل مجموعة أسطح إلى جسم واحد.", + "help.surface.sweep": "يحرّك منحنيات أو مقاطع رسم على امتداد مسار لتكوين سطح، بلا أغطية. استخدمه للمزاريب والأشرطة الزخرفية وسائر الأشكال الرقيقة التي تتبع مسارًا.", + "help.surface.thicken": "يمنح جسمًا سطحيًا سماكة ويحوّله إلى جسم صلب. استخدمه لصنع قطع قابلة للطباعة أو التشغيل من الأسطح.", + "help.surface.trim": "يقص سطحًا بمستوى أو سطح آخر أو منحنيات رسم، ويبقي الجانب الذي تختاره. استخدمه لقص الأسطح إلى الشكل المطلوب قبل الخياطة.", + "help.surface.unstitch": "يفصل أوجه جسم إلى أجسام سطحية مستقلة؛ فيتفكك الجسم الصلب إلى أسطح. استخدمه لإعادة العمل على أوجه مفردة من جسم.", + "help.surface.untrim": "يزيل الثقوب من وجه، أو يستعيد حدوده الخارجية إلى الحدود الطبيعية للسطح. استخدمه لاستعادة السطح الكامل بعد القص.", "help.third_party": "رخص الأطراف الثالثة", "help.timeline.compute_all": "يعيد بناء كل ميزة في السجل من البداية. استخدمه لتحديث النموذج ولمعرفة الميزات التي تفشل.", "help.timeline.delete": "يحذف الميزات المحددة من السجل، وحذف رسمٍ يحذف معه الميزات المبنية عليه. استخدمه لإزالة خطوة لم تعد مطلوبة.", + "help.timeline.dependents": "يسرد الميزات التي ستُحذف مع ميزة ما، وتلك التي ستفشل بدونها. استخدمه قبل حذف ميزة أو تغييرها.", "help.timeline.play": "أعد تشغيل الميزات المتبقية واحدة تلو الأخرى، مع توقف لمدة نصف ثانية بعد كل إعادة بناء. شغّل هذا الأمر مرة أخرى أو اضغط Escape للإيقاف؛ ينتقل «الذهاب إلى النهاية» فورًا إلى النهاية.", + "help.view.apply": "يدير العرض إلى اتجاه قياسي مثل الأمامي أو العلوي أو الأيزومتري، أو إلى عرض مسمّى محفوظ. استخدمه للنظر إلى النموذج من زاوية ثابتة.", + "help.view.delete": "يحذف عرضًا مسمّى محفوظًا. لا يتغير النموذج.", + "help.view.fit": "يضبط التكبير ويُزيح العرض حتى يملأ النموذج كله أو التحديد منطقة العرض. استخدمه حين يخرج النموذج من مجال الرؤية.", + "help.view.grid": "يُظهر الشبكة في منطقة العرض أو يخفيها. استخدمها كدليل للحجم والاتجاه أثناء النمذجة.", + "help.view.ground_shadow": "يُظهر ظل النموذج على الأرض أو يخفيه. استخدمه لتقدير الارتفاع والموضع بسهولة أكبر.", + "help.view.home": "يعيد العرض إلى الاتجاه الرئيسي ويلائم النموذج. استخدمه للعودة سريعًا إلى عرض معروف.", + "help.view.list": "يسرد العروض المسمّاة المحفوظة في التصميم. استخدمه للعثور على عرض حفظته سابقًا.", + "help.view.navigate": "يدير العرض أو يُزيحه أو يغيّر تكبيره بخطوات، مثل ربع دورة إلى اليسار. استخدمه لتحريك الكاميرا بدقة بدلًا من السحب.", + "help.view.navigation_bar": "يُظهر شريط التنقل أسفل منطقة العرض أو يخفيه. أخفِه لمساحة أكبر، وأظهره للوصول إلى أدوات العرض بالفأرة.", + "help.view.rename": "يمنح عرضًا مسمّى محفوظًا اسمًا جديدًا. يبقى العرض نفسه كما هو.", + "help.view.save": "يحفظ موضع الكاميرا الحالي كعرض مسمّى في التصميم. استخدمه للعودة إلى الزاوية نفسها لاحقًا، مثلًا للقطات الشاشة.", + "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": "تأكيد الحذف", @@ -1656,6 +1911,7 @@ "nav.hfss.help": "Alt مع السحب بالزر الأيسر يُزيح العرض، وShift+Alt مع الأيسر يكبّره، وCtrl مع السحب بالأيسر من مساحة فارغة يدوّره", "nav.inventor.help": "السحب بالزر الأوسط يُزيح العرض، وShift مع الأوسط يدوّره، وCtrl مع الأوسط يكبّره، والسحب بالأيمن يدوّره", "nav.maya.help": "Alt مع السحب بالزر الأيسر يدوّر العرض، وAlt مع الأوسط يُزيحه، وAlt مع الأيمن يكبّره؛ والسحب بالزر الأوسط يُزيح أيضًا", + "nav.nx.help": "السحب بالزر الأوسط يدوّر العرض، وShift مع الأوسط يُزيحه (في NX: الأوسط مع الأيمن)، وCtrl مع الأوسط يكبّره (في NX: الأوسط مع الأيسر)؛ والسحب بالأيمن لا يحرك الكاميرا", "nav.openscad.help": "السحب بالزر الأيسر على مساحة فارغة يدوّر العرض، وShift مع السحب بالأيسر يحدد بمربع، وسحب هندسة الرسم يعدّلها؛ والسحب بالأيمن أو الأوسط يُزيح، وShift مع السحب بالأيمن أو الأوسط يكبّر", "nav.perspective.sketch": "تحرير الرسم يستخدم العرض المتعامد. أنهِ الرسم للتبديل إلى المنظور.", "nav.right_drag_orbits.help": "عند الإيقاف: لا يحرّك السحب بالزر الأيمن الكاميرا، ويفتح الزر الأيمن قائمة السياق فقط. اختيار تعيين أزرار الفأرة SolidWorks يوقفه.", @@ -1775,8 +2031,8 @@ "not a move": "ليس تحريكًا", "not a number": "ليس عددًا", "open a sketch first": "افتح رسمًا أولًا", - "parameters.apply_live": "طبّق كل تغيير عند مغادرة حقله", - "parameters.apply_live_tip": "عند الإيقاف: تنتظر التغييرات حتى «تطبيق» أو «موافق»، ثم يُعاد بناء التصميم مرة واحدة لها جميعًا. «إلغاء» أو Escape يتجاهلها.", + "parameters.apply_live": "تطبيق كل تغيير عند مغادرة حقله", + "parameters.apply_live_tip": "عند الإيقاف: تنتظر التغييرات حتى «تطبيق» أو «موافق»، ثم يُعاد بناء التصميم مرة واحدة لها جميعًا. «إلغاء» أو Escape يلغيها.", "parameters.held": "{count} تغييرات بانتظار «تطبيق»", "parameters.held_hint": "عدّل التعابير والوحدات. تنتظر التغييرات «تطبيق» أو «موافق».", "parameters.held_one": "تغيير واحد بانتظار «تطبيق»", @@ -1857,7 +2113,7 @@ "prefs.bottom_right": "أسفل اليمين", "prefs.browser": "المستعرض", "prefs.browser_eye_left": "أيقونة الإظهار بجانب سهم الطي", - "prefs.browser_eye_left_note": "تعرض صفوف المستعرض أيقونة الإظهار بجانب سهم الطي كما كانت من قبل، بدلاً من عمود في الطرف الأيمن للصفوف.", + "prefs.browser_eye_left_note": "تعرض صفوف المستعرض أيقونة الإظهار/الإخفاء بجانب سهم الطي كما كانت من قبل، بدلًا من عمود عند نهاية الصفوف.", "prefs.browser_reveal_on_hover": "إظهار عند مرور المؤشر", "prefs.browser_show_note": "«ظاهر» يُبقي المستعرض على الشاشة. «إظهار عند مرور المؤشر» يُخفيه حتى يمر المؤشر فوق مكانه أو فوق الشريط الرفيع عند حافته اليسرى، ويبقى مفتوحًا ما دام المؤشر داخله أو كانت قائمة منه مفتوحة.", "prefs.browser_shown": "ظاهر", @@ -1877,6 +2133,8 @@ "prefs.ctrl_cmd": "Ctrl / Cmd", "prefs.curved_surfaces": "الأسطح المنحنية", "prefs.curves": "الأسطح المنحنية", + "prefs.dark_parts_shading": "الحفاظ على شكل الأجزاء الداكنة", + "prefs.dark_parts_shading_help": "تظليل العرض بإضاءة خطية مع إضاءة سماء ناعمة، لتحتفظ الأوجه الداكنة بتدرج بدل أن تصبح سوداء", "prefs.dead_zone_note": "الشريط المخطط هو المنطقة الميتة: تُتجاهل الحركة داخلها.", "prefs.default": "افتراضي", "prefs.design": "افتراضيات التصميم", @@ -1891,6 +2149,8 @@ "prefs.edges": "الحواف والتظليل", "prefs.extend": "توسيع", "prefs.extra_navigation": "Ctrl مع السحب بالزر الأيسر في مساحة فارغة يدوّر العرض، واضغط مطولًا على Space للإزاحة.", + "prefs.extrude_view_isometric": "التحويل إلى العرض المتساوي القياس عند فتح البثق", + "prefs.extrude_view_return": "العودة إلى العرض السابق عند إغلاق البثق", "prefs.follows_os": "يتبع النظام", "prefs.font": "الخط", "prefs.font_fallback": "الرموز التي يفتقدها الخط المختار تُرسم بالخط المدمج، فلا تظهر أي تسمية كمربع فارغ.", @@ -1906,10 +2166,11 @@ "prefs.graphics_card_auto": "تلقائي", "prefs.graphics_card_missing": "بطاقة الرسوميات المحفوظة غير متاحة؛ يُستخدم الخيار «تلقائي».", "prefs.graphics_card_restart": "يُطبَّق بعد إعادة تشغيل SolidCraft. يتجاوز WGPU_ADAPTER_NAME هذا الاختيار.", + "prefs.graphics_card_unused": "لم تُستخدم بطاقة الرسوميات المحفوظة لهذه النافذة؛ البطاقة المستخدمة تظهر في «المساعدة › حول» وفي شريط الحالة. جرّب «تلقائي»، أو عيّن WGPU_ADAPTER_NAME إلى جزء فريد من اسم البطاقة.", "prefs.graphics_note": "وازن بين السرعة والنعومة. كل خيار يعرض أثره على عيّنة.", "prefs.graphics_presets_note": "الإعدادات المسبقة تضبط الخيارات أدناه معًا.", "prefs.hide_constraint_glyphs_zoomed_out": "إخفاء رموز القيود عند التصغير", - "prefs.hide_constraint_glyphs_zoomed_out_note": "تختفي رموز القيود عندما تُصغَّر المسودة بما يكفي حتى لا تزحم العرض. معطّل: تبقى بحجم شاشة ثابت عند أي تكبير.", + "prefs.hide_constraint_glyphs_zoomed_out_note": "تختفي رموز القيود من العرض عندما يُصغَّر الرسم بقدر يجعلها تزحمه. معطّل: تبقى بحجم ثابت على الشاشة عند أي مستوى تكبير.", "prefs.history": "السجل", "prefs.history_origin_bodies": "إظهار الأصل والأجسام في الأعلى", "prefs.history_origin_bodies_note": "يبدأ السجل على اليسار أو تحت المستعرض بمجلد الأجسام للمكوّن النشط وبالأصل، مع أيقونات الإظهار والقوائم كما في المستعرض. لا يتغير السجل في الأسفل.", @@ -1920,6 +2181,8 @@ "prefs.history_sketches_note": "عند ضبط الرسومات على «تحت ميزتها»، يعرض السجل على اليسار كل رسم بمستوى واحد للداخل، تحت أول ميزة بُنيت منه، متى كان عريضًا بما يكفي لإظهار الأسماء. أما الخط الزمني في الأسفل فيحتفظ دائمًا بترتيب الإنشاء.", "prefs.history_sketches_under_feature": "تحت ميزتها", "prefs.hold_stop_snapping": "اضغط مطولًا لإيقاف الالتقاط", + "prefs.home_fit_size": "حجم العرض الرئيسي والملاءمة", + "prefs.home_fit_size_note": "مقدار ما يملؤه النموذج من العرض عندما يُظهره العرض الرئيسي أو الملاءمة أو تصميم مفتوح أو تغيير العرض من متصفح العرض، سواء كان الإعداد أدناه مفعّلًا أم لا. 100 %: يملأ العرض بين اللوحات والبوصلة وشريط التنقل.", "prefs.icons_only": "أيقونات فقط", "prefs.imported_file": "ملف مستورد", "prefs.in_browser": "في المستعرض", @@ -1928,13 +2191,20 @@ "prefs.interface_language": "لغة الواجهة", "prefs.interface_note": "شكل SolidCraft وأماكن أجزائه.", "prefs.interval_minutes": "{n} دقيقة", + "prefs.invert_navigator_arrows": "عكس أسهم تدوير مكعب العرض", + "prefs.invert_navigator_arrows_note": "تدور الأسهم حول المكعب بالكاميرا حسب اتجاه السهم، كما في Fusion وInventor: السهم على اليسار يشير يميناً ويحرّك الكاميرا يميناً. عند الإيقاف: يدور السهم بالمكعب نحو الجهة التي يقع عليها، فيحرّك سهم اليسار الكاميرا يساراً. لا يتأثر سهما التدوير المنحنيان.", + "prefs.keep_work_on_tab_switch": "إبقاء مربعات الحوار والأدوات مفتوحة عند التبديل بين التصاميم", + "prefs.keep_work_on_tab_switch_note": "تحتفظ كل علامة تبويب تصميم بمربع الحوار أو الأداة المفتوحة، بما حددته وكتبته فيها، وبعلامة تبويب شريط الأدوات. وتجدها كما هي عند العودة إلى التصميم. عند الإيقاف: يؤدي التبديل بين التصاميم إلى إلغائها وعرض علامة التبويب «النموذج».", "prefs.labels": "التسميات", + "prefs.language_system": "لغة النظام", "prefs.layout": "التخطيط", "prefs.layout_note": "المضغوط يثبّت اللوحات على حواف النافذة. شفافية المستعرض متاحة في الوضع العائم. غيّر جهة المساعد من صفحة المساعد.", "prefs.left": "يسار", "prefs.left_click": "نقر أيسر", "prefs.line_drag_draws_one": "الضغط والسحب يرسم خطًا واحدًا", "prefs.line_drag_draws_one_note": "في أداة الخط، الضغط ثم السحب ثم الإفلات يرسم خطًا واحدًا من نقطة الضغط إلى نقطة الإفلات وينتهي هناك. لا تزال النقرات ترسم سلسلة. عند الإيقاف: يضع السحب نقطة حيث يُفلت، كما تفعل النقرة.", + "prefs.line_hover_tangent_arc": "التوقف عند آخر نقطة في الخط للتبديل إلى قوس مماسي", + "prefs.line_hover_tangent_arc_note": "في أداة الخط، بعد رسم قطعة، ابتعد عن آخر نقطة في السلسلة ثم عد إليها وأبقِ المؤشر ثابتًا عليها: تحدد النقرة التالية نهاية قوس مماسي، والحركة نفسها تعيدك إلى الخطوط. عند الإيقاف: لا يحدث شيء عند التوقف هناك.", "prefs.linked_designs": "التصاميم المرتبطة", "prefs.live_input": "الإدخال المباشر", "prefs.live_preview": "المعاينة المباشرة", @@ -1958,6 +2228,8 @@ "prefs.mouse_mapping": "تعيين أزرار الفأرة", "prefs.navigation": "التنقل", "prefs.navigation_note": "كيف تحرك الفأرة ولوحة اللمس والفأرة ثلاثية الأبعاد العرض.", + "prefs.navigator_fit": "ملاءمة النموذج بعد تغيير العرض من متصفح العرض", + "prefs.navigator_fit_note": "النقر على وجه أو ركن أو سهم في المكعب، أو على محور في أداة المحاور، يوجّه العرض أيضًا نحو منتصف النموذج من المسافة الواحدة التي تُظهره كاملًا من كل جهة (كرته المحيطة). يستخدم العرض الرئيسي التكبير والمركز ذاتهما ما دام هذا مفعّلًا، فلا تغيّر العروض المختارة بعده سوى الاتجاه. عند الإيقاف: يدور العرض في مكانه محتفظًا بالهدف والتكبير.", "prefs.network_host_remove": "إزالة", "prefs.network_locations": "السماح بمواقع الشبكة في التصاميم", "prefs.network_locations_note": "عند الإيقاف: الملفات المرتبطة بالتصميم على حاسوب آخر (\\\\server\\share) لا تُفتح إلا من الخوادم المدرجة هنا، أو بعد أن تسمح لأحدها عند السؤال.", @@ -1979,7 +2251,7 @@ "prefs.prefer_free_sketch_points": "تفضيل نقاط الرسم القابلة للتحريك عند تداخل النقاط", "prefs.preview_choices": "معاينة اختياراتك", "prefs.print_face_highlight": "إبراز وجه الطباعة", - "prefs.print_face_highlight_tip": "مدى قوة تلوين وجه الطباعة للجسم وتحديده في العرض. ‏0 % لا يُظهر أي إبراز.", + "prefs.print_face_highlight_tip": "مدى قوة تلوين وجه الطباعة للجسم ورسم حدوده في العرض. القيمة 0 % لا تُظهر أي إبراز.", "prefs.profile_active": "ملف التعريف النشط", "prefs.profile_applied": "تم تطبيق ملف التعريف.", "prefs.profile_changed": "تغيّرت الإعدادات منذ حفظ ملف التعريف. «حفظ» يحفظها فيه.", @@ -2006,6 +2278,8 @@ "prefs.render_previous_geometry_budget": "استخدام ميزانية الهندسة السابقة لمعاينة التصيير", "prefs.render_previous_geometry_budget_help": "استعادة حد الهندسة الثابت السابق (512 ميبيبايت على سطح المكتب 64 بت، و128 ميبيبايت على المنصات الأخرى) بدلًا من الميزانية المعتمدة على الجهاز.", "prefs.reports_saved": "{n} محفوظة", + "prefs.ribbon_click_ends_tool": "النقر على زر الأداة قيد التشغيل ينهيها", + "prefs.ribbon_click_ends_tool_note": "النقر على زر الشريط المضيء لأداة الرسم المستخدمة ينهي الأداة، فيعود النقر في العرض إلى التحديد. عند الإيقاف: يبدأ النقر الأداة من جديد.", "prefs.ribbon_reveal": "إظهار مكان نتائج البحث في الشريط", "prefs.ribbon_reveal_note": "عند تمرير المؤشر فوق نتيجة في «البحث في الأوامر» يظهر مكان الأمر في الشريط: تبويبه أو زره أو صفه في القائمة المنسدلة للوحة. ويومض هناك الأمر الذي بدأ من البحث، ويُعلِّم الأمر الجاري من قائمة منسدلة لوحتَه.", "prefs.right_click": "نقر أيمن", @@ -2035,6 +2309,8 @@ "prefs.sketch_exit_view.previous": "العودة إلى العرض السابق", "prefs.sketch_exit_view.unless_rotated": "العودة ما لم يُدوَّر العرض", "prefs.sketch_exit_view_note": "أين ينتقل العرض بعد إنهاء الرسم أو الإلغاء أو الضغط على Esc مرتين.", + "prefs.sketch_face_activates_part": "الرسم على وجه جزء يجعل ذلك الجزء نشطًا", + "prefs.sketch_face_activates_part_note": "الرسم الذي يبدأ على وجه جسم في مكوّن آخر ينشّط ذلك المكوّن أولًا، فينتمي الرسم إليه ويبقى مع ذلك الجزء في المستعرض. ويبقى الجزء نشطًا بعد ذلك. عند الإيقاف: ينتمي الرسم إلى المكوّن النشط.", "prefs.sketch_open_look_at": "توجيه العرض نحو الرسم عند فتحه", "prefs.sketch_open_look_at_note": "عند الإيقاف: إنشاء الرسم أو تحريره يُبقي العرض كما هو. يوجّه «النظر إلى» العرض نحو الرسم عند الحاجة.", "prefs.sketch_recent_snap": "الالتقاط التلقائي إلى المراكز ونقاط المنتصف الأخيرة", @@ -2048,6 +2324,8 @@ "prefs.sketch_tab_always_note": "يبقى تبويب الرسم في الشريط بعد تبويب النموذج مباشرة حتى عندما لا يكون أي رسم مفتوحًا. عندها تبدأ أدوات الرسم فيه رسمًا على المستوي أو الوجه المحدد، أو على المستوي الذي تختاره بعد ذلك، وتكون الأداة جاهزة فيه.", "prefs.sketch_tracking_guides": "أدلة محاذاة المراكز ونقاط المنتصف", "prefs.sketch_tracking_guides_note": "مرّر المؤشر فوق مركز دائرة أو نقطة منتصف حافة مستقيمة، ثم حرّكه أفقيًا أو عموديًا لإظهار دليل منقّط مؤقت.", + "prefs.sketch_tracking_points": "أدلة محاذاة من نقاط النهاية والزوايا أثناء الرسم", + "prefs.sketch_tracking_points_note": "عندما تنتظر أداة رسم نقطة، يؤدي تمرير المؤشر فوق نهاية خط أو قوس أو زاوية مستطيل أو نقطة حرة إلى إظهار الدليل المنقّط المؤقت نفسه الذي يظهر للمركز أو نقطة المنتصف. عند الإيقاف: المراكز ونقاط المنتصف فقط.", "prefs.sketching": "الرسم", "prefs.snap_drawing_note": "أثناء الرسم أو سحب نقطة في الرسم.", "prefs.source": "المصدر", @@ -2064,6 +2342,24 @@ "prefs.status_error": "خطأ", "prefs.status_selected": "محدد", "prefs.status_warning": "تحذير", + "prefs.structure.appearance": "المظهر", + "prefs.structure.appearance_note": "الألوان والنصوص وإمكانية الوصول.", + "prefs.structure.in_profile": "ضمن ملف التعريف", + "prefs.structure.layout": "التخطيط", + "prefs.structure.layout_note": "مواضع أجزاء النافذة.", + "prefs.structure.look": "المظهر", + "prefs.structure.measure_pointer": "القياس والمؤشر", + "prefs.structure.mouse3d": "فأرة ثلاثية الأبعاد", + "prefs.structure.mouse3d_note": "SpaceMouse وSpaceNavigator.", + "prefs.structure.navigation_note": "الفأرة ولوحة اللمس. للفأرة ثلاثية الأبعاد صفحة خاصة بها.", + "prefs.structure.profile": "ملف التعريف", + "prefs.structure.profile_edited": "تغيّر منذ الحفظ", + "prefs.structure.profile_hover": "محفوظ في ملفات تعريف الإعدادات", + "prefs.structure.scale_percent": "{percent}%", + "prefs.structure.sketch": "رسم", + "prefs.structure.tools": "الأدوات", + "prefs.structure.tools_note": "كيفية عمل أدوات النمذجة.", + "prefs.structure.work": "العمل", "prefs.supersampling_brief": "يرسم العرض بحجم أكبر ثم يصغّره: حاد جدًا لكنه أثقل.", "prefs.tab_accent_note": "لون التمييز يتبع التبويب النشط", "prefs.theme_file": "ملف السمة", @@ -2075,6 +2371,16 @@ "prefs.theme_follow_off": "إيقاف", "prefs.theme_follow_stop": "إيقاف الاتباع", "prefs.theme_import_hint": "انقر لاختيار ملف سمة، أو أفلت ملفًا هنا، أو اضغط Ctrl+V (Cmd+V على Mac) بعد نسخ سمة", + "prefs.theme_option_opacity": "العتامة", + "prefs.theme_option_opacity_help": "اضبط عتامة اللوحات العائمة. يُطبّق حد أدنى للعتامة للحفاظ على وضوح النص فوق ألوان منطقة العرض.", + "prefs.theme_option_tab_colours": "ألوان علامات التبويب", + "prefs.theme_option_tab_colours_help": "استخدم ألوان اللوحات ومنطقة العرض من هذه السمة لكل علامة تبويب في الشريط.", + "prefs.theme_option_tab_fade_ms": "انتقال علامات التبويب", + "prefs.theme_option_tab_fade_ms_help": "تتغيّر ألوان اللوحات ومنطقة العرض تدريجيًا عند تبديل علامات التبويب. القيمة صفر تبدّلها فورًا.", + "prefs.theme_options_badge": "الخيارات", + "prefs.theme_options_more": "المزيد · {count}", + "prefs.theme_options_reset": "إعادة تعيين", + "prefs.theme_options_unavailable": "غير متاح على هذا الجهاز", "prefs.theme_paste": "لصق السمة", "prefs.theme_paste_none": "لا تحتوي الحافظة على سمة SolidCraft", "prefs.timeline_half_hour": "−30 دقيقة", @@ -2102,15 +2408,17 @@ "prefs.view_confirm_corner": "موافق وإلغاء في زاوية العرض", "prefs.view_confirm_corner_note": "أثناء فتح مربع حوار أو رسم، تؤدي علامة الصح وعلامة الإكس أعلى يمين العرض ما يؤديه زرّا موافق وإلغاء في مربع الحوار، أو تنهي الرسم وتلغيه. وينتقل متصفح العرض إلى أسفلهما.", "prefs.viewport": "منطقة العرض", - "prefs.viewport_analysis_status": "إظهار حالة التحليل في منفذ العرض", - "prefs.viewport_analysis_status_help": "عرض التحليلات النشطة أعلى منفذ العرض وفتح مفتاح كل تحليل من عنصره. ينهي Esc أحدث تحليل. أوقف الخيار لاستعادة المفاتيح في الزاوية وسلوك Esc السابق.", + "prefs.viewport_analysis_status": "إظهار حالة التحليل في منطقة العرض", + "prefs.viewport_analysis_status_help": "عرض التحليلات النشطة أعلى منطقة العرض، وفتح مفتاح الألوان لكل تحليل من عنصره، واستخدام Esc لإيقاف أحدث تحليل. أوقف الخيار لاستعادة مفاتيح الألوان في الزاوية وسلوك Esc السابق.", "prefs.viewport_background": "الخلفية", "prefs.viewport_background_colour": "لون", "prefs.viewport_background_theme": "تدرج السمة", - "prefs.viewport_section_status": "إظهار حالة المقطع في منفذ العرض", - "prefs.viewport_section_status_help": "عرض إعداد المقطع في منفذ العرض. يُوقف Escape وزر الشريط النشط المقطع. عطّل الخيار للاحتفاظ بأدوات المقطع السابقة.", - "prefs.viewport_status_summary": "تلخيص عناصر حالة منفذ العرض", - "prefs.viewport_status_summary_help": "اجمع ثلاثة عناصر أو أكثر لحالة منفذ العرض في شارة ملخّصة واحدة. عطّل الخيار لإبقاء كل عنصر في الصف الكامل.", + "prefs.viewport_section_status": "إظهار حالة المقطع في منطقة العرض", + "prefs.viewport_section_status_help": "عرض إعداد المقطع في منطقة العرض. يُوقف Escape وزر الشريط النشط المقطع. عطّل الخيار للاحتفاظ بأدوات المقطع السابقة.", + "prefs.viewport_status_details": "تفاصيل منطقة العرض المباشرة", + "prefs.viewport_status_details_help": "حرّر إعدادات التحليل والمقطع النشطين في بطاقات التفاصيل القياسية. عطّل هذا الخيار لاستعادة مفاتيح ألوان التحليل للقراءة فقط ومربع حوار المقطع.", + "prefs.viewport_status_summary": "تلخيص عناصر حالة منطقة العرض", + "prefs.viewport_status_summary_help": "اجمع ثلاثة عناصر أو أكثر لحالة منطقة العرض في شارة ملخّصة واحدة. عطّل الخيار لإبقاء كل عنصر في الصف الكامل.", "prefs.wheel": "العجلة", "prefs.wheel_trackpad": "العجلة ولوحة اللمس", "prefs.workspace": "مساحة العمل", @@ -3549,6 +3857,7 @@ "sketch_text.formula": "ابدأ بـ = لتكوين النص من المعاملات: ='W' + number(Width)", "sketch_text.italic": "مائل", "sketch_text.loading_fonts": "جارٍ البحث عن الخطوط المثبتة…", + "sketch_text.no_bold": "الخط المدمج لا يحتوي على نمط غامق: اختر خطًا مثبتًا", "sketch_text.on_path": "النص يتبع مساره: المنحنى يحدد موضعه", "sketch_text.path_clear": "فصل النص عن منحناه", "sketch_text.path_hint": "منحنى من الرسم يتبعه النص: انقر هنا ثم على المنحنى", @@ -3757,6 +4066,7 @@ "ui.agent_chat_auth_terminal": "شغّل هذا في الطرفية، ثم اضغط «اتصال» مرة أخرى:", "ui.agent_chat_auth_title": "تسجيل الدخول إلى الوكيل", "ui.agent_chat_cancel": "إلغاء", + "ui.agent_chat_cannot_start": "مثبّت، لكنه لا يبدأ: {error}", "ui.agent_chat_changes": "التغييرات: {n}، خطوة تراجع واحدة", "ui.agent_chat_changes_n": "{n} تغييرات", "ui.agent_chat_changes_of": "{n}/{limit} تغييرات", @@ -3849,6 +4159,7 @@ "ui.agent_chat_queued_stopped": "في الانتظار (متوقف، لم يُرسل)", "ui.agent_chat_refresh": "تحديث", "ui.agent_chat_refused": "غير مسموح للوكيل: {reason}", + "ui.agent_chat_reinstall": "{agent} مثبّت ({program})، لكنه لم يبدأ عندما فحصه SolidCraft: {error}\nقد يكون تثبيته غير مكتمل، أو مُعدًّا لنوع آخر من الحواسيب. ثبّته من جديد بالأمر `{install}`، وتحقّق من أنه يعرض رقم إصداره في الطرفية، ثم اضغط تحديث.", "ui.agent_chat_remove": "إزالة من قائمة الانتظار", "ui.agent_chat_remove_file": "إزالته من هذه الرسالة", "ui.agent_chat_remove_imported": "إزالته من هذه الرسالة؛ تبقى القطعة في التصميم (التراجع يزيلها)", @@ -3912,12 +4223,17 @@ "ui.ai_copy_suffix": "نسخة", "ui.ai_custom_url": "عنوان URL مخصص", "ui.ai_default": "الافتراضي", + "ui.ai_edit": "تعديل", + "ui.ai_edit_agent_title": "تعديل الوكيل", + "ui.ai_edit_gone": "أُزيل هذا الوكيل، لذا لا يوجد ما يُحفظ.", + "ui.ai_edit_tip": "غيّر اسم هذا الوكيل أو أمره أو نقطة الاتصال الخاصة به. يبقى المفتاح المحفوظ مخفيًا.", "ui.ai_http_busy": "نقطة النهاية مشغولة أو تجاوزت حدها؛ حاول لاحقًا", "ui.ai_http_key": "رفضت نقطة النهاية المفتاح: تحقق منه في التفضيلات › الذكاء الاصطناعي", "ui.ai_http_not_found": "لا توجد نقطة نهاية أو نموذج بهذا الاسم: تحقق من عنوان URL الأساسي واسم النموذج", "ui.ai_http_too_large": "المحادثة أكبر من أن تقبلها نقطة النهاية هذه؛ ابدأ محادثة جديدة", "ui.ai_key": "مفتاح API", "ui.ai_key_hint": "اختياري؛ يُحفظ في سلسلة مفاتيح النظام", + "ui.ai_key_keep_hint": "اتركه فارغًا للإبقاء على المفتاح المحفوظ", "ui.ai_key_needs_https": "لا يُرسل المفتاح إلا عبر HTTPS أو إلى هذا الحاسوب.", "ui.ai_key_note": "يُخزّن في سلسلة مفاتيح النظام، وليس في إعدادات SolidCraft أبدًا. اتركه فارغًا لخادم محلي.", "ui.ai_keychain_error": "رفضت سلسلة مفاتيح النظام: {error}", @@ -3944,11 +4260,15 @@ "ui.ai_permissions": "الأذونات", "ui.ai_preset": "إعداد مسبق", "ui.ai_protocol": "البروتوكول", + "ui.ai_reasoning_effort": "مستوى الاستدلال", + "ui.ai_reasoning_effort_field_hint": "فارغ = الافتراضي للمزوّد", + "ui.ai_reasoning_effort_hint": "اختياري. يُرسل إلى نقطة النهاية على أنه `reasoning_effort`؛ تختلف القيم المقبولة حسب المزوّد (مثل `low` أو `high` أو `max`). لا تستبدل SolidCraft القيمة التي تحددها هنا أبدًا. اتركه فارغًا لعدم إرسال شيء والاحتفاظ بالافتراضي للمزوّد.", "ui.ai_remove": "إزالة", "ui.ai_request_timed_out": "استغرق النموذج أكثر من {seconds} ثانية. يمكنك رفع مهلة الطلب لنقطة النهاية هذه من التفضيلات › المساعد.", "ui.ai_request_timeout": "مهلة الطلب", "ui.ai_request_timeout_hint": "المدة التي يمكن أن يستغرقها طلب واحد للنموذج إلى نقطة النهاية هذه، من 30 ثانية إلى ساعة. ارفعها للنماذج المحلية البطيئة.", "ui.ai_reset": "إعادة تعيين", + "ui.ai_save": "حفظ", "ui.ai_settings": "إعدادات الذكاء الاصطناعي…", "ui.ai_show_features": "إظهار ميزات الذكاء الاصطناعي", "ui.ai_show_features_note": "محادثة الوكيل: لوحة يعمل فيها وكيل برمجي تستخدمه أصلًا، أو نقطة نهاية نموذج تختارها، على التصميم المفتوح. عند الإيقاف: لا زر ولا اختصار ولا عنصر قائمة ولا لوحة للذكاء الاصطناعي، ولا يُبحث عن شيء أو يُشغّل.", @@ -4108,6 +4428,7 @@ "ui.dark": "داكن", "ui.default_modelling_orientation": "اتجاه النمذجة الافتراضي", "ui.delete": "حذف", + "ui.delete_select_first": "حدّد أولًا ما تريد حذفه: الميزات أو الأجسام أو المكونات أو هندسة الرسم", "ui.depth": "العمق", "ui.design": "التصميم", "ui.design_properties.author": "المؤلف", @@ -4122,6 +4443,40 @@ "ui.design_properties.project": "المشروع", "ui.design_properties.revision": "المراجعة", "ui.design_saved": "تم الحفظ في {path}", + "ui.details_access_caption": "توضح الأوجه ما إذا كان بإمكان الأداة الوصول إليها من الاتجاه المحدد.", + "ui.details_angle": "الزاوية", + "ui.details_continuity_caption": "حرّر الوصلة المحددة. تحتفظ الوصلات الأخرى بإعداداتها الخاصة.", + "ui.details_continuity_stale": "هندسة سابقة: أعد تشغيل التحليل لتحرير هذه الوصلة أو ملاءمة العرض لها.", + "ui.details_copy_list": "نسخ القائمة", + "ui.details_curvature": "تفاوت الانحناء", + "ui.details_curvature_caption": "تُلوَّن الأوجه وفقًا لنصف قطر الانحناء المحلي لها.", + "ui.details_direction": "اتجاه الأداة", + "ui.details_draft_caption": "تُلوَّن الأوجه وفقًا لزاويتها بالنسبة إلى اتجاه السحب.", + "ui.details_edit_seam": "تحرير الوصلة", + "ui.details_fixture": "نموذج عرض لمكوّن الواجهة — بيانات توضيحية فقط", + "ui.details_fixture_caption": "نموذج عرض لمكوّن الواجهة بقيم توضيحية؛ هذه ليست نتيجة من النموذج الهندسي.", + "ui.details_fixture_problem": "مثال على نتيجة تشير إلى مشكلة", + "ui.details_fixture_readout": "مثال على قيمة مقاسة", + "ui.details_flip": "قلب", + "ui.details_gap": "الفجوة", + "ui.details_hatch": "تهشير أوجه القطع", + "ui.details_jump": "قفزة الانحناء", + "ui.details_key": "مفتاح الألوان", + "ui.details_offset": "إزاحة", + "ui.details_plane": "مستوى", + "ui.details_pull": "اتجاه السحب", + "ui.details_radius": "نصف القطر", + "ui.details_readout": "القراءات", + "ui.details_samples": "العينات", + "ui.details_seam": "{body} · حافة {edge}", + "ui.details_settings": "الإعدادات", + "ui.details_sharp_gap": "حافة حادة أو فجوة", + "ui.details_show_all": "عرض الكل ({count})", + "ui.details_smooth": "أملس", + "ui.details_stripes": "الخطوط", + "ui.details_target": "ملاءمة العرض للهندسة", + "ui.details_worst_seams": "الوصلات الأقل استمرارية", + "ui.details_zebra_caption": "تُظهر الخطوط المنعكسة مدى سلاسة اتصال الأوجه.", "ui.diameter": "القطر", "ui.dimension": "البُعد", "ui.direct_interaction": "التفاعل المباشر", @@ -4166,6 +4521,7 @@ "ui.edit_in_place_saved_elsewhere": "حُفظ الجزء في ملف آخر؛ ما زال التجميع مرتبطًا بـ {name}", "ui.edit_in_place_wait": "انتظر حتى ينتهي ما يجري في التصميم", "ui.edit_section_analysis": "تعديل تحليل المقطع", + "ui.edit_sketch_empty": "لا توجد رسومات لتعديلها", "ui.ellipse": "قطع ناقص", "ui.emboss_wrap_note": "تلتف ملامح الخطوط المتعامدة المماسة لأسطوانة مدعومة تلقائيًا. اختر جدارًا لتحديد الهدف.", "ui.end_radius": "نصف قطر النهاية", @@ -4176,12 +4532,15 @@ "ui.equation_explicit": "صريح", "ui.equation_form": "الشكل", "ui.equation_note": "المتغير رقم بلا وحدة: تأخذه sin(t) بالراديان. والنتيجة التي بلا وحدة تكون بوحدة التصميم.", + "ui.equation_note_range": "المتغير طول مثل نطاقه: يمكن دمجه مع معاملات الطول، واقسمه على طول قبل تمريره إلى sin(). والنتيجة التي بلا وحدة تكون بوحدة التصميم.", "ui.equation_parametric": "بارامتري", + "ui.equation_variable_unit": "المتغير يأخذ وحدة النطاق", "ui.equation_x_of_t": "x(t)", "ui.equation_y_of_t": "y(t)", "ui.equation_y_of_x": "y(x)", "ui.equivalent_radius": "نصف القطر المكافئ", "ui.expand": "توسيع", + "ui.explode_needs_two_components": "يُبعد العرض المفكك المكوّنات بعضها عن بعض، لذا يحتاج التصميم إلى مكوّنين أو أكثر (مكوّن جديد، أو إدراج تصميم، أو إنشاء مكوّنات من الأجسام)", "ui.exportDxf": "تصدير DXF…", "ui.export_ellipsis_keymap": "تصدير…", "ui.export_keymap_tip": "المفاتيح ومفضلات مربع S وشريط الأدوات، كملف", @@ -4350,12 +4709,14 @@ "ui.joints": "المفاصل", "ui.keyboard_shortcuts": "الاختصارات", "ui.language": "اللغة", + "ui.lasso_select": "التحديد الحر (ارسم حلقة حرة بالسحب: يحدّد ما يقع داخلها بالكامل)", "ui.layout_compact": "مضغوط", "ui.layout_floating": "عائم", "ui.length": "الطول", "ui.length_decimals": "المنازل العشرية للأطوال", "ui.light": "فاتح", "ui.line": "خط", + "ui.line_arc_hint": "قوس مماسي: انقر نهاية القوس. توقف عند آخر نقطة مرة أخرى للعودة إلى الخطوط؛ انقر بالزر الأيمن أو اضغط Esc للإنهاء", "ui.link_copies": "ربط النسخ المتطابقة", "ui.link_copies_keep": "إبقاء", "ui.link_copies_link": "ربط", @@ -4444,6 +4805,7 @@ "ui.part_size_shared": "يعيد بناء القطعة مستبدلًا أي ميزات مضافة. تتحدث كل نسخ هذا المكوّن.", "ui.paste": "لصق", "ui.path": "المسار", + "ui.pattern_object_type_tip": "الأجسام: أشكال مصمتة تُختار في العرض أو في المستعرض؛ يُنسخ كل منها كاملًا.\nالميزات: خطوات في السجل، مثل بثق أو ثقب، تُختار في السجل أو على وجه صنعته؛ تتكرر الخطوة.\nالرسم ليس أيًّا منهما: كرّر منحنياته بأداة النمط أو المرآة داخل الرسم، أو ابثقه أولًا.", "ui.pattern_on_path": "نمط على مسار", "ui.perforate_margin_default": "من القطر", "ui.perforate_pitch": "الخطوة", @@ -4509,6 +4871,7 @@ "ui.properties.inertia_y": "Iyx, Iyy, Iyz", "ui.properties.inertia_z": "Izx, Izy, Izz", "ui.properties.measuring": "جارٍ القياس…", + "ui.properties.measuring_bodies": "جارٍ قياس الأجسام… {done} من {total}", "ui.properties.measuring_failed": "توقف القياس قبل أن يكتمل.", "ui.properties.principal_axis": "المحور الرئيسي {number}", "ui.properties.principal_moments": "العزوم الرئيسية", diff --git a/assets/locales/bs.json b/assets/locales/bs.json index 38c0420bea..542a49ff73 100644 --- a/assets/locales/bs.json +++ b/assets/locales/bs.json @@ -60,6 +60,7 @@ "BLEND SURFACE": "PRELAZNA POVRŠ", "BOUNDARY SURFACE": "GRANIČNA POVRŠ", "BROWSER": "STABLO MODELA", + "Back to the original order": "Nazad na izvorni redoslijed", "Ball": "Kuglasti", "Bar": "Šipka", "Base Center": "Centar osnove", @@ -595,6 +596,8 @@ "Snap to face and edge centres": "Privlači na centre lica i ivica", "SolidCraft cannot submit reports directly. Submit on GitHub; no token is needed in the app.": "SolidCraft ne može slati izvještaje direktno. Pošaljite ih na GitHubu; u aplikaciji nije potreban token.", "SolidCraft closed before these designs were saved. Their last autosave can be opened.": "SolidCraft se zatvorio prije nego što su ovi dizajni sačuvani. Može se otvoriti njihovo posljednje automatsko čuvanje.", + "Sort A to Z": "Sortiraj od A do Ž", + "Sort Z to A": "Sortiraj od Ž do A", "Spacing 2 must be a non-zero number": "Razmak 2 mora biti broj različit od nule", "Spacing must be a non-zero number": "Razmak mora biti broj različit od nule", "Specialise": "Specijalizuj", @@ -819,6 +822,7 @@ "cmd.appearance_pick_up": "Preuzmi izgled", "cmd.appearance_replace": "Zamijeni izgled", "cmd.assembly_bom": "Sastavnica", + "cmd.assembly_mass_properties": "Svojstva mase", "cmd.body.print_face": "Površina za štampu", "cmd.body_rename": "Preimenuj tijelo", "cmd.browser_collapse_all": "Skupi sve", @@ -1244,6 +1248,7 @@ "cmd.view.render_browser": "Stablo renderovanja", "cmd.view.render_dock": "Premjesti panel renderovanja", "cmd.view.render_focus": "Prikaz fokusa renderovanja", + "cmd.view.status_details": "Detalji statusa prozora prikaza", "cmd.view.status_items": "Stavke statusa prozora prikaza", "cmd.view_ambient_occlusion": "Ambijentalna okluzija", "cmd.view_angle_readout": "Uglovi prikaza", @@ -1387,21 +1392,168 @@ "from the rule": "iz pravila", "help.apache": "Apache licenca 2.0", "help.appearance.assign": "Daje izabranim tijelima, licima ili komponentama boju ili izgled materijala, bez promjene njihove težine. Koristite ga da razlikujete dijelove ili pregledate završnu obradu.", + "help.assembly.bom": "Navodi dijelove dizajna ili komponente s njihovim količinama, po broju dijela, a popis može sačuvati kao CSV ili JSON. Koristite je kao popis dijelova za naručivanje i montažu.", "help.attributions": "Atribucije (ATTRIBUTION.md)", "help.build": "Verzija {version} · commit {commit} · izgrađeno {date}", + "help.canvas.decal": "Postavlja sliku na ravno lice modela, poput naljepnice. Koristite je da prikažete etiketu, logotip ili štampani uzorak na dijelu.", + "help.canvas.edit": "Mijenja platno: njegov položaj, veličinu, ugao, neprozirnost, preslikavanje, vidljivost ili naziv. Koristite ga da poravnate referentnu sliku sa skicom kojom je precrtavate.", + "help.canvas.insert": "Postavlja PNG ili JPEG sliku na ravan ili lice kao prozirnu referencu. Koristite je da u skici precrtate fotografiju ili crtež, uz sliku skaliranu na poznatu veličinu.", + "help.component.break_link": "Odvaja povezanu komponentu od njene datoteke dizajna, a njena tijela ostavlja kakva jesu. Koristite ga kada dio više ne treba pratiti izmjene izvorne datoteke.", + "help.component.capture_position": "Zadržava položaje dijelova koje ste ručno pomjerili, tako da to postaju njihovi stvarni položaji. Koristite ga nakon što ručno namjestite dijelove koje nijedan spoj ne drži.", + "help.component.create": "Dodaje novu, praznu komponentu u dizajn, unutar aktivne komponente osim ako izaberete drugog roditelja. Koristite je da novi dio sklopa dobije vlastito mjesto za tijela i skice.", "help.component.finish_edit": "Čuva dio koji uređujete na mjestu, vraća se u njegov sklop i ažurira sklop prema njemu. Upotrijebite kada završite izmjene povezanog dijela.", + "help.component.from_bodies": "Pretvara svako izabrano tijelo u zasebnu komponentu, nazvanu po tijelu. Koristite ga kada dizajn izgrađen od odvojenih tijela treba postati sklop čiji se dijelovi mogu spajati i pomjerati.", + "help.component.insert_design": "Umeće drugi sačuvani dizajn kao komponentu povezanu s njegovom datotekom, s njegovim punim tijelima postavljenim onako kako su tamo. Koristite ga da ponovo upotrijebite dio u sklopu i kasnije izmjene preuzmete pomoću Ažuriraj veze.", + "help.component.properties": "Prikazuje zapreminu, masu, centar mase i momente inercije komponente, prema materijalima njenih tijela. Koristite ga da provjerite težinu i uravnoteženost sklopa.", + "help.component.revert_position": "Vraća dijelove koje ste ručno pomjerili tamo gdje su bili, odbacujući pomjeranja koja nisu zadržana. Koristite ga da poništite probna pomjeranja u sklopu.", + "help.component.update_links": "Ponovo čita povezane datoteke dizajna i zamjenjuje tijela tamo gdje su se promijenila. Koristite ga nakon uređivanja dijela koji ovaj sklop koristi.", + "help.config.configure": "Dodaje parametre, operacije, materijale ili izglede kao kolone tabele konfiguracija. Koristite ga da napravite varijante jednog dizajna, npr. veličine nekog dijela.", + "help.config.preview": "Provjerava može li se red tabele konfiguracija ponovo izgraditi, bez promjene dizajna ili njegove vremenske trake. Koristite ga prije prelaska na varijantu u koju niste sigurni.", + "help.config.rules": "Prikazuje pravila tabele konfiguracija, njene redove i koji je red aktivan. Koristite ga da vidite kako su postavljene varijante dizajna.", + "help.config.table": "Prikazuje tabelu konfiguracija: jedan red po varijanti, jednu kolonu po parametru ili operaciji i da li dizajn još odgovara aktivnom redu. Koristite je da pregledate varijante dizajna.", "help.connect_note": "Ponovo pokrenite svaki asistent da učita SolidCraft server. Svaka datoteka je sigurnosno kopirana kao .bak prije promjene.", "help.connect_title": "Poveži AI asistente", + "help.construct.axis.edge": "Pravi pomoćnu osu duž ravne ivice ili linije skice. Koristite je kao osu za rotaciju, kružni niz ili spoj.", + "help.construct.axis.normal_to_face": "Pravi pomoćnu osu kroz tačku, okomitu na lice. Koristite je kao osu koja stoji pravo iz površine.", + "help.construct.axis.through_cylinder": "Pravi pomoćnu osu kroz centar cilindričnog, konusnog ili torusnog lica, ili kružne ivice. Koristite je da operacije okrećete ili ih postavljate u niz oko okruglog dijela.", + "help.construct.axis.two_planes": "Pravi pomoćnu osu gdje se sastaju dvije ravni ili ravna lica. Koristite je kada je osa koja vam treba linija između dva lica.", + "help.construct.axis.two_points": "Pravi pomoćnu osu kroz dvije tačke, vrhove ili tačke skice. Koristite je za osu koja prolazi između dva poznata položaja.", + "help.construct.plane.along_path": "Pravi pomoćnu ravan okomitu na ivicu ili krivu, na nekom položaju duž nje. Koristite je da skicirate profil za povlačenje ili presjek putanje.", + "help.construct.plane.at_angle": "Pravi pomoćnu ravan zakrenutu za ugao u odnosu na ravan ili lice, oko ose ili ivice. Koristite je za nagnute skice, npr. kutni nosač.", + "help.construct.plane.midplane": "Pravi pomoćnu ravan na pola puta između dvije ravni ili ravna lica, ili koja ih raspolavlja tamo gdje se sastaju pod uglom. Koristite je kao ravan simetrije za preslikavanje.", + "help.construct.plane.offset": "Pravi pomoćnu ravan paralelnu s ravni ili ravnim licem, na određenoj udaljenosti ili kroz tačku. Koristite je da skicirate na zadatoj visini iznad lica.", + "help.construct.plane.perpendicular": "Pravi pomoćnu ravan koja sadrži liniju ili ivicu i okomita je na ravan ili lice. Koristite je da skicirate uspravno na modelu duž ivice.", + "help.construct.plane.tangent": "Pravi pomoćnu ravan koja u jednoj tački dodiruje cilindar, konus, sferu ili torus. Koristite je da skicirate na boku okruglog dijela.", + "help.construct.plane.three_points": "Pravi pomoćnu ravan kroz tri tačke. Koristite je za ravan određenu položajima na modelu, npr. s tri ugla.", + "help.construct.plane.two_edges": "Pravi pomoćnu ravan kroz dvije ravne ivice ili linije koje leže u jednoj ravni. Koristite je za ravan kroz dio, npr. kroz dvije suprotne ivice.", + "help.construct.point.along_path": "Pravi pomoćnu tačku na nekom položaju duž ivice ili krive. Koristite je kao referencu negdje duž putanje.", + "help.construct.point.center": "Pravi pomoćnu tačku u centru kružne ivice, kruga ili luka skice, sfere ili torusa. Koristite je kao referencu za rupe, ose i spojeve.", + "help.construct.point.edge_plane": "Pravi pomoćnu tačku gdje ivica ili linija prolazi kroz ravan ili ravno lice. Koristite je da označite gdje putanja siječe ravan.", + "help.construct.point.three_planes": "Pravi pomoćnu tačku gdje se sastaju tri ravni ili ravna lica. Koristite je kao referencu ugla, čak i tamo gdje model nema vrh.", + "help.construct.point.two_edges": "Pravi pomoćnu tačku gdje se dvije ivice ili linije sastaju ili su najbliže. Koristite je kao referencu u virtualnom uglu.", + "help.construct.point.vertex": "Pravi pomoćnu tačku na vrhu ili tački skice. Koristite je kao referencu koja ostaje kada se skica ili tijelo oko nje sakrije.", + "help.contact.check": "Navodi parove dijelova u skupovima kontakata koji se u trenutnom položaju međusobno presijecaju. Koristite ga da pronađete sudare prije nego što pokrenete spojeve ili studiju kretanja.", + "help.contact.create": "Grupiše dva ili više dijelova u skup kontakata čija se tijela sprečavaju da prolaze jedno kroz drugo dok se spojevi kreću. Koristite ga za dijelove koji treba da se zaustave kad se dodirnu, npr. poluga uz svoj graničnik.", + "help.contact.disable_all": "Isključuje kontakt, pa se pokretani spojevi i studije kretanja više ne zaustavljaju tamo gdje se dijelovi dodiruju. Koristite ga da ponovo slobodno pomjerate dijelove ili da veliki sklop ostane brz u radu.", + "help.contact.edit": "Mijenja skup kontakata: dijelove koje sadrži, njegov naziv ili da li je potisnut; može i izbrisati skup. Koristite ga da podesite koji dijelovi ne smiju prolaziti jedan kroz drugi.", + "help.contact.enable_all": "Stavlja svaki dio sklopa u jedan skup kontakata, pa nijedna dva dijela ne mogu prolaziti jedan kroz drugi dok se spojevi kreću. Koristite ga za brzu provjeru cijelog mehanizma.", + "help.contact.enable_sets": "Uključuje kontakt za skupove kontakata koje ste definisali, pa se pokretani spojevi i studije kretanja zaustavljaju tamo gdje bi njihovi dijelovi prošli jedan kroz drugi. Koristite ga da vratite svoje skupove nakon isključivanja kontakta.", + "help.contact.list": "Navodi skupove kontakata dizajna, dijelove u svakom od njih i da li je kontakt uključen. Koristite ga da provjerite kako je kontakt postavljen prije nego što pokrenete sklop.", + "help.document.inspect": "Prikazuje pregled dizajna: njegove komponente, tijela, skice, operacije i parametre. Koristite ga da se snađete u dizajnu koji niste sami napravili.", + "help.document.units": "Mijenja jedinice u kojima dizajn prikazuje dužine, npr. mm ili inče. Geometrija zadržava veličinu; mijenjaju se samo prikaz i brojevi bez jedinice.", + "help.edit.checkpoint": "Pamti dizajn, aktivnu skicu i historiju poništavanja onakve kakvi su sada. Koristite ga prije nego što isprobate nešto rizično od čega ćete se možda htjeti vratiti.", + "help.edit.restore_checkpoint": "Vraća se na kontrolnu tačku, uključujući historiju poništavanja. Koristite ga da jednim korakom odbacite sve urađeno od kontrolne tačke.", + "help.explode.create": "Pravi rastavljeni prikaz koji odguruje dijelove od centra sklopa ili prema pomjeranjima koja zadate. Koristite ga da pokažete kako se sklop sastavlja, za uputstva ili prezentacije.", "help.face.delete": "Uklanja izabrana lica i pušta da susjedna lica zatvore otvor, ili ostavlja otvor otvorenim kao površ. Koristite ga da uklonite zaobljenu ivicu, rupu ili ispupčenje koje smeta.", + "help.file.insert_mesh": "Dodaje 3MF, STL, OBJ, PLY ili glTF datoteku u dizajn kao mrežna tijela u novoj komponenti. Koristite ga da pored svog dizajna kao referencu postavite preuzeti ili skenirani model.", + "help.file.insert_step": "Dodaje STEP, IGES, FreeCAD ili Rhino datoteku u dizajn kao puna tijela u novoj komponenti; dijelovi sklopa postaju ugniježđene komponente. Koristite ga da uvezete modele od dobavljača ili iz drugih CAD programa.", + "help.file.save_mesh": "Čuva tijela kao STL, 3MF ili OBJ mrežu, s glatkoćom zakrivljenih lica koju izaberete. Koristite ga da pripremite dijelove za 3D štampu ili za programe koji čitaju mreže.", + "help.file.send_to_slicer": "Čuva izabrana tijela kao 3MF datoteku i otvara je u slajseru koji izaberete. Koristite ga da od modela do štampe dođete jednim korakom.", "help.font_details": "Interfejs koristi ugrađene fontove biblioteke egui (Ubuntu Light i Hack, pod licencama Ubuntu Font Licence i MIT/Bitstream Vera; Noto Emoji i font emoji ikona, pod licencama OFL i MIT). Tekst skice koristi Liberation Sans (SIL OFL 1.1). Neobavezni paket fontova Crafting Apps, craft-fonts (BIZ UDPGothic, Shippori Mincho, BIZ UDMincho, Noto Sans CJK SC, Noto Sans Arabic; SIL OFL 1.1), nije uključen u izdanja SolidCrafta.", "help.fonts": "Fontovi", + "help.frame.member": "Postavlja profil, npr. cijev ili aluminijski ekstruzioni profil, duž linija skice konstrukcije, po jedan konstrukcioni profil na liniju. Koristite ga za izradu okvira, regala i zavarenih konstrukcija.", + "help.inspect.accessibility": "Boji lica do kojih alat može doći iz jednog smjera i ona do kojih ne može. Koristite ga da provjerite dio za obradu ili izradu u kalupu s te strane.", + "help.inspect.center_of_mass": "Prikazuje centar mase tijela, svakog posebno i svih zajedno, uz ujednačenu gustoću. Koristite ga da provjerite ravnotežu i na koju stranu će se dio prevrnuti.", + "help.inspect.colored_components": "Daje svakoj komponenti vlastitu privremenu boju, bez promjene izgleda. Koristite ga da na prvi pogled razlikujete dijelove sklopa.", + "help.inspect.compare": "Upoređuje dva tijela i pokazuje gdje jedno ima materijala kojeg drugo nema, uz zapreminu i odstupanje. Koristite ga da provjerite izmijenjen ili uvezen dio u odnosu na original.", + "help.inspect.continuity": "Provjerava koliko glatko se lica sastaju duž svojih ivica: otvorene krajeve, pregibe ili skokove zakrivljenosti, i boji spojeve. Koristite ga da pronađete gdje se površi ne spajaju čisto.", + "help.inspect.curvature_comb": "Crta češljeve duž kriva, ivica ili lica čiji zupci pokazuju koliko se jako savijaju. Koristite ga da uočite ravna mjesta, pregibe i neujednačenu zakrivljenost.", + "help.inspect.curvature_map": "Boji lica prema tome koliko su zakrivljena, crveno gdje je poluprečnik mali. Koristite ga da pronađete mjesta oštre zakrivljenosti i procijenite koliko je površ glatka.", + "help.inspect.draft": "Boji lica prema uglu nagiba u odnosu na smjer vađenja iz kalupa i označava ona s premalim nagibom. Koristite ga da provjerite dio prije brizganja ili livenja.", + "help.inspect.environment_map": "Prikazuje model sa sjajnom završnom obradom koja reflektuje poput ogledala. Koristite ga da procijenite tok površi prema načinu na koji se refleksije protežu preko njih.", + "help.inspect.interference": "Pronalazi tijela koja se preklapaju i prikazuje svako preklapanje s njegovom zapreminom. Koristite ga da u sklopu provjerite ima li dijelova koji se sudaraju.", + "help.inspect.isocurve": "Crta izokrive lica (izoparametarske krive), mrežu na kojoj je površ izgrađena. Koristite ga da vidite kako površ teče i gdje se zgušnjava.", + "help.inspect.measure": "Mjeri dužine, udaljenosti, uglove, površine i poluprečnike između onoga što izaberete, ili zapreminu i masu tijela. Koristite ga da provjerite veličine i zazore.", + "help.inspect.minimum_radius": "Pronalazi najmanji poluprečnik zakrivljenosti kriva, ivica ili lica i gdje se nalazi. Koristite ga da provjerite može li alat ili mlaznica pratiti oblik.", + "help.inspect.section": "Otvara prikaz presjekom duž ravni i šrafira presječena lica, bez promjene modela. Koristite ga da pogledate unutrašnjost dijelova i sklopova.", + "help.inspect.zebra": "Prikazuje crno-bijele pruge reflektovane na licima modela. Koristite ga da procijenite glatkoću: pruge koje se prekidaju ili lome na ivici pokazuju loš prelaz.", + "help.joint.as_built": "Spaja dva dijela tamo gdje već jesu, bez pomjeranja ijednog od njih, i određuje kako se još smiju kretati. Koristite ga za dijelove koje ste već postavili u konačni položaj.", + "help.joint.cam": "Povezuje brijeg koji se okreće s pratiocem, pa pratilac klizi ili se ljulja dok obris brijega prolazi ispod njega. Koristite ga za brijegove, ekscentre i pogone ventila.", + "help.joint.create": "Spaja dva dijela u ishodištima spoja koja izaberete i pomjera drugi dio na mjesto; vrsta spoja određuje kako se još smiju kretati. Koristite ga da napravite sklop čiji se dijelovi kreću kao pravi mehanizam.", + "help.joint.drive": "Zadaje ugao ili udaljenost spoja i s njim pomjera dijelove, u okviru njegovih granica; povezani spojevi i zatvorene petlje ga prate. Koristite ga da provjerite kreće li se mehanizam kako je zamišljeno.", + "help.joint.gear": "Povezuje dva rotaciona spoja prijenosnim omjerom, pa pokretanje jednog okreće drugi. Koristite ga za čeone i prstenaste zupčanike, s omjerom iz podionih poluprečnika ili broja zubaca.", + "help.joint.limits": "Zadaje koliko daleko se spoj smije okrenuti ili pomjeriti, kao minimum i maksimum, ili uklanja te granice. Koristite ga da šarku, klizač ili polugu zadržite unutar njihovog stvarnog raspona kretanja.", + "help.joint.motion_link": "Veže dva spoja omjerom i pomakom, pa pokretanje jednog pomjera drugi. Koristite ga za čvrst odnos između spojeva koji Zupčanik, Zupčasta letva i zupčanik ili Vijak ne pokrivaju.", + "help.joint.origin": "Postavlja imenovano ishodište spoja na dio: u centar lica ili okruglog kraja, sredinu ivice, ugao ili tačku. Koristite ga da pripremite gdje se spojevi vezuju; ishodište izabrano na licu prati ga kada dio promijeni veličinu.", + "help.joint.rack_pinion": "Povezuje mali zupčanik koji se okreće sa zupčastom letvom koja klizi, pa se letva pomjera za dužinu podione kružnice koju mali zupčanik prevali. Koristite ga za pogone sa zupčastom letvom, upravljanje i linearne stolove.", + "help.joint.reach": "Pomjera tačku na dijelu prema cilju mijenjajući spojeve u njegovom lancu, u okviru njihovih granica. Koristite ga da namjestite mehanizam prema tome gdje treba biti njegov kraj, npr. vrh robotske ruke.", + "help.joint.rigid_group": "Zaključava izabrane dijelove zajedno onakve kakvi jesu, pa se kreću kao jedan. Koristite ga za dijelove koji zajedno pripadaju i između kojih nema kretanja.", + "help.joint.screw": "Povezuje okretanje i klizanje korakom, pa se svakim okretajem napreduje za tu udaljenost. Koristite ga za navojna vretena, vijke i navojne osovine.", + "help.library.apply": "Primjenjuje materijal ili izgled iz lokalne datoteke biblioteke na tijela, lica ili komponente. Koristite ga da ponovo upotrijebite materijale koje ste sačuvali iz drugog dizajna.", + "help.library.export": "Čuva materijale i izglede dizajna u datoteku biblioteke. Koristite ga da ih dijelite ili ponovo koristite u drugim dizajnima.", + "help.library.inspect": "Provjerava datoteku biblioteke i navodi njene materijale i izglede, bez promjene dizajna. Koristite ga da vidite šta biblioteka sadrži prije nego što je primijenite.", "help.licences": "Licence", "help.material.assign": "Određuje od čega su izabrana tijela napravljena, npr. čelik, aluminij ili ABS. Koristite ga da masa i težina odgovaraju stvarnom dijelu.", + "help.material.properties.validate": "Provjerava karticu podataka materijala na usklađenost brojeva i vrijednosti koje nedostaju. Koristite ga prije nego što se oslonite na podatke za masu ili krutost.", + "help.mesh.convert": "Ponovo gradi mrežno tijelo, npr. STL, kao uredivo puno tijelo s ravnim i cilindričnim licima. Koristite ga da izmijenite uvezene mehaničke dijelove kao modelirano tijelo.", "help.mit": "MIT licenca", + "help.motion.study": "Zadaje vrijednosti spojeva u koracima na vremenskoj traci i reprodukuje ih kao animaciju. Koristite ga da posmatrate kako se mehanizam kreće kroz niz pokreta i usput provjerite zazore.", + "help.motion.trace": "Prati putanju kojom ide tačka na dijelu dok se reprodukuje studija kretanja ili se pokreće spoj i, kada potvrdite, zadržava je kao krivu 3D skice. Koristite ga da vidite dokle mehanizam dosiže ili da nastavite graditi na njegovoj putanji.", "help.notice": "NOTICE", + "help.occurrence.ground": "Učvršćuje dio na mjestu, ili ga ponovo oslobađa, tako da spojevi pomjeraju ostale dijelove oko njega. Koristite ga za okvir ili osnovu na kojoj je izgrađen ostatak sklopa.", "help.occurrence.move": "Pomjera dio sklopa samo duž stepeni slobode koje dozvoljavaju njegovi spojevi. Izaberite lice dijela, a zatim prevucite dostupnu strelicu ili luk.", "help.parameters.change": "Otvara tabelu imenovanih vrijednosti i izraza koji upravljaju kotama dizajna. Koristite je da promijenite mjeru na jednom mjestu, a sve operacije koje je koriste je slijede.", + "help.parameters.export": "Čuva parametre dizajna kao CSV ili JSON. Koristite ga da ih uredite u proračunskoj tabeli ili prenesete u drugi dizajn.", + "help.parameters.graph": "Prikazuje koji parametri i operacije koriste koje druge, te sve petlje ili greške među njima. Koristite ga da vidite šta sve zahvata promjena nekog parametra.", + "help.parameters.import": "Učitava parametre iz CSV ili JSON u dizajn, sve ili nijedan. Koristite ga da unesete vrijednosti iz proračunske tabele ili drugog dizajna.", + "help.parameters.move": "Pomjera korisnički parametar ispred ili iza drugog na popisu. Koristite ga da povezane parametre držite zajedno.", + "help.parts.fastener": "Dodaje standardni vijak, zavrtanj, maticu, podlošku, zatik ili ležaj u izabranoj veličini. Postavljen na rupu, naliježe u rupu pomoću spoja, pa spojne elemente ne morate sami modelirati.", + "help.parts.insert": "Umeće standardni dio u porodici, veličini i dužini koje izaberete. Postavljen na rub ili zid rupe, naliježe na rupu krutim spojem.", + "help.parts.library": "Otvara standardne dijelove: vijke, zavrtnjeve, matice, podloške, bezglave vijke, cilindrične zatike i ležajeve u standardnim veličinama. Koristite ga da dodate standardni dio umjesto da ga sami modelirate.", + "help.parts.resize": "Mijenja veličinu ili dužinu umetnutog standardnog dijela i ponovo ga gradi; njegovi spojevi ga drže na mjestu. Koristite ga kada se vijak pokaže predugim ili rupa traži drugu veličinu.", + "help.plastic.assign_rule": "Dodjeljuje pravilo za plastiku tijelima, pa njihove plastične operacije preuzimaju njegov nagib i zazor kao podrazumijevane vrijednosti. Koristite ga da jednom podesite brizgani dio za sve njegove operacije.", + "help.plastic.boss": "Dodaje ispust za vijak na licu: stub s neobaveznom rupom, nagibom, zaobljenjem u korijenu i potpornim rebrima. Koristite ga za tačke za vijke u brizganim ili štampanim kućištima.", + "help.plastic.lip": "Dodaje usnu ili odgovarajući utor duž ruba izdubljenog tijela. Koristite ga da se dvije polovine kućišta međusobno pozicioniraju i čvrsto zatvore.", + "help.plastic.manage_rules": "Pravi i uređuje pravila za plastiku: materijal, debljinu zida, poluprečnike, zazor i nagib. Koristite ih da operacije brizganog dijela ostanu usklađene.", + "help.plastic.rest": "Dodaje oslonac na licu: okruglu ili pravougaonu podlogu, punu ili šuplju, s nagibom. Koristite ga kao potporu ili odstojnik u brizganom kućištu.", + "help.plastic.snap_fit": "Dodaje kuku škljocajućeg spoja na licu, s njenom dužinom, debljinom i zapinjačem. Koristite ga da dijelove spojite zaklapanjem bez vijaka.", "help.pointcloud.insert": "Umetnite PLY, XYZ ili PTS skeniranje kao oblak tačaka uz model. Koristite ga kao referencu pri izradi modela iz izmjerenih podataka.", + "help.sheet.convert": "Pretvara ploču ili izdubljeno tijelo ujednačene debljine u tijelo od lima, sa savijanjima na ivicama koje izaberete. Koristite ga da postojeći dio učinite razvijivim.", + "help.sheet.export_bend_table": "Čuva tabelu savijanja tijela od lima kao CSV ili JSON: uglove, poluprečnike i položaje savijanja u razvijenom obliku. Koristite ga za presu za savijanje ili radionicu.", + "help.sheet.export_dxf": "Čuva razvijeni oblik kao DXF, s obrisima, linijama savijanja i neobaveznim napomenama o savijanju. Koristite ga za rezanje laserom, plazmom ili vodenim mlazom.", + "help.sheet.flange": "Dodaje prirubnicu: osnovnu ploču iz skice, zid duž otvorene konture skice ili savijeni zid na ivicama lima. Koristite je za izgradnju dijelova od lima.", + "help.sheet.flat_pattern": "Pravi razvijeni oblik tijela od lima, s njegovom veličinom, izrezima i linijama savijanja. Koristite ga da provjerite pripremak prije rezanja.", + "help.sheet.fold": "Savija lim duž linije skice za zadati ugao i poluprečnik. Koristite ga da presavijete ravan lim nakon što u njemu izrežete operacije.", + "help.sheet.hem": "Presavija ivicu lima natrag preko sebe. Koristite ga za sigurne, krute ivice bez oštrih srha.", + "help.sheet.manage_rules": "Pravi i uređuje pravila za lim: debljinu, poluprečnik savijanja, K-faktor, rasterećenja i razmake. Koristite ih da se uskladite s materijalom i alatima svoje radionice.", + "help.sheet.path_flange": "Dodaje lanac prirubnica duž ivice lima, prateći otvoreni lanac linija skice. Koristite ga za profile poput U ili Z oblika u jednom koraku.", + "help.sheet.perforate": "Dodaje polje rupa preko cijelog ravnog lima, u izmaknutom ili kvadratnom rasporedu. Koristite ga za otvore za ventilaciju, rešetke i lakše ploče.", + "help.sheet.refold": "Ponovo presavija razvijeno tijelo od lima. Koristite ga nakon dodavanja operacija na ravan lim.", + "help.sheet.unfold": "Razvija sva savijanja tijela od lima. Koristite ga da dodate rupe ili rezove koji moraju prelaziti preko savijanja, a zatim ga ponovo presavijte.", + "help.sketch.arc.center_point": "Crta kružni luk oko tačke centra, od početne do završne tačke. Koristite ga kada je centar luka poznat, npr. poluprečnik oko rupe ili osovine.", + "help.sketch.arc.tangent": "Crta luk koji počinje na kraju linije ili luka i nastavlja se tangentno na njega. Koristite ga da lanac nastavite glatkim prelazom umjesto ugla.", + "help.sketch.arc.three_point": "Crta luk od početne do završne tačke kroz treću tačku. Koristite ga kada luk mora proći kroz zadate tačke, a ne oko poznatog centra.", + "help.sketch.arc3d": "Crta tačan kružni luk u prostoru kroz tri tačke, uz uključenu 3D skicu. Koristite ga za zavoje putanja i vodilica koji izlaze iz ravni skice.", + "help.sketch.auto_constrain": "Dodaje ograničenja koja skica već gotovo ima, npr. horizontalno, paralelno ili podudarno, a zatim je kotira dok ne bude potpuno definisana. Koristite ga da grubo nacrtanu skicu zaključate u jednom koraku.", + "help.sketch.auto_constrain_from_datum": "Radi kao Automatska ograničenja, ali položaje mjeri od referentne tačke koju izaberete umjesto od ishodišta. Koristite ga kada je dio raspoređen od jedne referentne tačke, npr. ugla ili rupe.", + "help.sketch.blend_curve": "Spaja krajeve dviju kriva glatkim splajnom koji je tangentan na obje, ili i kontinuiran po zakrivljenosti ako tako izaberete. Koristite ga da premostite razmak između kriva bez pregiba.", + "help.sketch.break": "Dijeli krivu na dijelove tamo gdje je presijecaju druge krive, ili na tačkama koje postavite na nju. Koristite ga kada dio krive treba vlastito ograničenje ili ga treba zasebno izbrisati.", + "help.sketch.cancel": "Napušta skicu i odbacuje sve što je promijenjeno otkako je napravljena ili otvorena za uređivanje. Pomoću Poništi možete vratiti promjene.", + "help.sketch.centerline": "Prebacuje linije između običnih linija i središnjih linija, osa koje profili izostavljaju. Središnju liniju koristite kao osu za rotaciju, preslikavanje ili ograničenje simetrije.", + "help.sketch.chamfer.distance_angle": "Skošava ugao između dvije linije, zadat udaljenošću duž prve linije i uglom. Koristite ga kada crtež daje skošenje kao dužinu i ugao, npr. 2 mm × 30°.", + "help.sketch.chamfer.equal_distance": "Skošava ugao između dvije linije za istu udaljenost unazad duž obje. Koristite ga za uobičajeno skošenje od 45° na obrisu.", + "help.sketch.chamfer.two_distance": "Skošava ugao između dvije linije, zadat posebnom udaljenošću duž svake linije. Koristite ga za skošenja koja nisu 45°, zadana kao dvije dužine.", + "help.sketch.circle.center": "Crta krug iz njegovog centra i prečnika. Koristite ga za rupe, ispuste i druge okrugle oblike čiji je centar poznat.", + "help.sketch.circle.three_point": "Crta krug kroz tri tačke. Koristite ga kada krug mora proći kroz zadate tačke, npr. postojeće geometrije.", + "help.sketch.circle.three_tangent": "Crta krug koji dodiruje tri elementa: linije, krugove ili lukove. Koristite ga da krug uklopite u prostor između triju kriva, npr. upisani krug trougla.", + "help.sketch.circle.two_point": "Crta krug iz dva kraja njegovog prečnika. Koristite ga kada su poznate tačke na rubu kruga, a ne njegov centar.", + "help.sketch.circle.two_tangent": "Crta krug zadate veličine koji dodiruje dva elementa: linije, krugove ili lukove. Koristite ga za zaobljenje stalnog poluprečnika između dvije krive.", + "help.sketch.conic": "Crta konusnu krivu između dvije krajnje tačke, oblikovanu tjemenom tačkom i vrijednošću rho. Koristite je za glatke prelaze čiju punoću želite podesiti, od ravnog do gotovo oštrog ugla.", + "help.sketch.constrainer": "Primjenjuje ograničenje koje jedna ili dvije izabrane krive već gotovo imaju, npr. horizontalno, paralelno, tangentno ili jednako. Koristite ga da uredite ručno nacrtanu skicu, jedan odnos po jedan.", + "help.sketch.constraint.coincident": "Čini da tačka leži na drugoj tački, krivoj ili osi skice. Koristite ga da zatvorite otvore u profilu ili da tačku postavite na liniju.", + "help.sketch.constraint.collinear": "Čini da dvije linije leže na jednoj pravoj. Koristite ga da poravnate prekinute ivice, npr. obje strane zareza.", + "help.sketch.constraint.concentric": "Daje dva kruga ili luka isti centar. Koristite ga za prstenove, cilindrična upuštenja i osovine s ramenima.", + "help.sketch.constraint.curvature": "Čini da dvije krive koje se sastaju u krajnjoj tački nastave istom zakrivljenošću (G2). Koristite ga tamo gdje tangentni spoj nije dovoljno gladak, npr. na vidljivim površima.", + "help.sketch.constraint.equal": "Izjednačava dužine linija, ili poluprečnike krugova i lukova. Koristite ga da jedna kota upravlja s više istovjetnih oblika.", + "help.sketch.constraint.fix": "Fiksira tačke ili krive tamo gdje jesu, ili ih ponovo oslobađa. Koristite ga za geometriju koja se ne smije pomjerati kada povlačite ili rješavate skicu.", + "help.sketch.constraint.horizontal_vertical": "Čini liniju, ili liniju između dvije tačke, horizontalnom ili vertikalnom, zavisno od toga što je bliže. Koristite ga da obris poravnate s osama skice.", + "help.sketch.constraint.midpoint": "Postavlja tačku na sredinu linije, ili spaja središnje tačke dviju linija. Koristite ga da geometriju centrirate na ivici.", + "help.sketch.constraint.parallel": "Čini dvije linije paralelnima. Koristite ga za stranice koje moraju ostati paralelne kada se promijene druge kote.", + "help.sketch.constraint.perpendicular": "Čini da se dvije linije sastaju pod pravim uglom. Koristite ga za prave uglove između linija koje nisu na osama skice.", + "help.sketch.constraint.polygon": "Čini zatvoreni lanac linija pravilnim poligonom: jednake stranice, a svaki ugao na jednom krugu. Koristite ga da ručno nacrtan poligon učinite pravilnim i takvim ga zadržite.", + "help.sketch.constraint.symmetry": "Čini da dvije tačke ili krive budu međusobno preslikane preko linije ili oko tačke. Koristite ga da profil ostane simetričan kada promijenite jednu stranu.", + "help.sketch.constraint.tangent": "Čini da se dvije krive dodiruju tangentno, npr. linija i luk. Koristite ga za glatke spojeve bez pregiba.", + "help.sketch.construction": "Prebacuje krive između obične geometrije i pomoćne geometrije, koju profili izostavljaju. Pomoćne krive koristite kao pomagala za raspored, reference i kote.", "help.sketch.construction_line": "Napravite beskonačnu pomoćnu pravu kroz dvije tačke. Koristite je da rasporedite skicu bez razdvajanja profila.", "help.sketch.construction_line.angle": "Napravite beskonačnu pomoćnu pravu pod uglom od ose X skice. Izaberite tačku i upišite ugao ili izaberite smjer.", "help.sketch.construction_line.bisector": "Izaberite dvije prave linije skice koje se sijeku, pa jednu od dvije simetrale ugla. Rezultat prati izvorne linije.", @@ -1412,11 +1564,71 @@ "help.sketch.construction_line.tangent_external": "Izaberite krug ili kružni luk, pa vanjsku tačku i rješenje tangente. Pregledi prikazuju samo dodirne tačke na nacrtanom luku.", "help.sketch.construction_line.tangent_two": "Izaberite dva kruga ili kružna luka, pa u pregledu izaberite zajedničku tangentu. Dodirne tačke moraju ležati na nacrtanim krivama.", "help.sketch.construction_line.vertical": "Napravite beskonačnu vertikalnu pomoćnu pravu kroz tačku. Njeno ograničenje vertikalnosti ostaje na snazi pri kasnijim izmjenama.", + "help.sketch.copy": "Kopira izabrane krive i tačke skice, zajedno s ograničenjima i kotama među njima. Zalijepite ih u ovu skicu ili u drugu.", + "help.sketch.create": "Počinje novu 2D skicu na ravni ili na ravnom licu modela. Svako ekstrudiranje, rotiranje i povlačenje počinje skicom.", + "help.sketch.cut": "Kopira izabranu geometriju skice i briše je iz skice, u jednom koraku poništavanja. Zalijepite je drugdje u ovoj skici ili u drugoj.", + "help.sketch.dimension": "Dodaje pogonsku kotu: dužinu, udaljenost, ugao, poluprečnik ili prečnik, zavisno od onoga što izaberete. Upišite vrijednost ili naziv parametra, a skica ih prati.", + "help.sketch.edit": "Otvara postojeću skicu za uređivanje. Operacije izgrađene na njoj se ažuriraju kada završite skicu.", + "help.sketch.ellipse": "Crta elipsu iz njenog centra, kraja velike ose i tačke na maloj strani. Koristite je za ovalne otvore, brijegove i druge izdužene okrugle oblike.", "help.sketch.equation_curve": "Crta krivu iz formula: x(t) i y(t) ili y = f(x), u zadanom rasponu. Formule mogu koristiti parametre i kriva ih prati: koristite je za sinusoidu, spiralu ili evolventu zupca zupčanika.", + "help.sketch.extend": "Produžava liniju ili luk do najbliže krive na putu, ili izdužuje dvije krive dok se ne sastanu. Koristite ga da zatvorite otvore u profilu nakon odsijecanja ili uređivanja.", + "help.sketch.fillet": "Zaobljuje ugao gdje se dvije linije ili lukovi sastaju lukom zadatog poluprečnika. Više uglova zaobljenih zajedno dijeli jedan poluprečnik.", + "help.sketch.finish": "Napušta skicu i vraća se na model. Njeni zatvoreni profili tada su spremni za Ekstrudiraj, Rotiraj i druge operacije.", + "help.sketch.finish_auto_constrain": "Pokreće Automatska ograničenja na skici, a zatim je završava. Koristite ga da skicu ostavite potpuno definisanu u jednom koraku.", + "help.sketch.fit_curves_to_section": "Siječe mrežno ili puno tijelo ravni skice i uklapa linije i lukove u presjek. Koristite ga da skenirani ili uvezeni dio ponovo izgradite kao uredivu skicu.", + "help.sketch.include_3d": "Unosi ivice modela, vrhove i krive drugih skica u ovu skicu kao povezane reference, spljoštavajući one izvan njene ravni na nju. Koristite ga da gradite na geometriji koja ne leži u ravni skice.", + "help.sketch.infinite": "Prebacuje linije između običnih linija i beskonačnih pomoćnih pravih koje se protežu preko cijelog prikaza. Koristite ih kao reference za raspored koje nikada ne završe prekratko.", + "help.sketch.insert_dxf": "Učitava krive DXF crteža u skicu, skalirane, zakrenute i postavljene gdje zadate. Koristite ga da gradite na 2D crtežu iz drugog programa, npr. na obrisu za rezanje laserom.", + "help.sketch.insert_svg": "Učitava putanje SVG crteža u skicu, skalirane, zakrenute i postavljene gdje zadate. Koristite ga da logotip ili vektorski obris pretvorite u krive koje možete ekstrudirati.", + "help.sketch.inspect": "Otvara izvještaj o otvorenoj skici: koliko je stepeni slobode preostalo i je li potpuno ograničena. Koristite ga da saznate šta se još pomjera prije nego što završite skicu.", + "help.sketch.intersect": "Dodaje krive ili tačke gdje tijela, lica, ivice ili druge skice sijeku ravan skice. Ostaju povezane i prate model kada se promijeni.", + "help.sketch.intersection_curve": "Pravi 3D krive tamo gdje se dva tijela ili lica sastaju, povezane s njima. Koristite ih kao putanje ili vodilice koje prate model.", + "help.sketch.isoparametric_curve": "Pravi 3D krivu u smjeru U ili V lica kroz tačku koju izaberete, povezanu s licem. Koristite je kao putanju ili liniju razdvajanja koja prati površ.", + "help.sketch.line": "Crta povezane linije tačku po tačku, kao otvoreni lanac ili zatvoren obris. To je osnovni alat za većinu profila.", + "help.sketch.line.midpoint": "Crta liniju od njene središnje tačke do jednog kraja, a drugi kraj se preslikava. Koristite je za linije centrirane na tački, npr. simetričnu ivicu.", + "help.sketch.line3d": "Crta povezane prave linije između tačaka u prostoru, uz uključenu 3D skicu. Koristite ga za putanje i okvire koji izlaze iz ravni skice.", + "help.sketch.mirror": "Preslikava krive i tačke skice preko linije ili ose i dodaje ograničenja simetrije. Koristite ga da nacrtate pola simetričnog profila i dovršite ga.", + "help.sketch.move": "Pomjera ili rotira krive i tačke skice, ili njihovu kopiju. Koristite ga da promijenite položaj geometrije uz zadržavanje njenog oblika i ograničenja.", + "help.sketch.offset": "Pravi kopiju lanca kriva na zadatoj udaljenosti, s jedne strane ili s obje. Koristite ga za zidove, obrise i zazore; udaljenost ostaje kota koju možete uređivati.", + "help.sketch.paste": "Lijepi geometriju skice koju ste kopirali ili izrezali u aktivnu skicu. Postavite je na tačku ili je pomjerite za odmak.", + "help.sketch.pattern.circular": "Kopira geometriju skice oko tačke centra, preko punog kruga ili zadatog ugla. Koristite ga za kružne rasporede vijaka, paoke i zupce.", + "help.sketch.pattern.rectangular": "Kopira geometriju skice u redovima i kolonama na zadatim razmacima. Koristite ga za mreže rupa, žlijebova ili otvora za ventilaciju.", + "help.sketch.point": "Postavlja tačku skice. Koristite je kao referencu za kote, ograničenja, centre rupa ili kasnije operacije.", + "help.sketch.point3d": "Postavlja tačku u prostoru, uz uključenu 3D skicu. Koristite je kao referencu za 3D krive i putanje.", + "help.sketch.polygon.circumscribed": "Crta pravilan poligon oko kruga, zadat od centra do sredine stranice. Koristite ga kada je poznata širina ključa, npr. za šestougaonu maticu.", + "help.sketch.polygon.edge": "Crta pravilan poligon iz dva kraja jedne stranice. Koristite ga kada su poznate dužina i položaj stranice.", + "help.sketch.polygon.inscribed": "Crta pravilan poligon unutar kruga, zadat od centra do ugla. Koristite ga kada je poznata mjera preko uglova.", + "help.sketch.project": "Unosi ivice modela, lica, vrhove i krive drugih skica u ovu skicu, projektovane na njenu ravan. Ostaju povezani i prate model kada se promijeni.", + "help.sketch.project_to_surface": "Projektuje krive drugih skica duž normale ove skice na lice, kao povezane 3D krive. Koristite ga da logotip, liniju razdvajanja ili putanju postavite na zakrivljenu površ.", + "help.sketch.rectangle.center": "Crta pravougaonik iz njegovog centra i jednog ugla. Koristite ga za obrise centrirane na tački, npr. na ishodištu.", + "help.sketch.rectangle.rounded": "Crta pravougaonik sa sva četiri ugla zaobljena istim poluprečnikom. Koristite ga za ploče, poklopce i otvore sa zaobljenim uglovima.", + "help.sketch.rectangle.three_point": "Crta pravougaonik iz jedne ivice i tačke na suprotnoj strani. Koristite ga za pravougaonike pod uglom u odnosu na ose skice.", + "help.sketch.rectangle.two_point": "Crta pravougaonik iz dva suprotna ugla. Koristite ga za obrise ploča, blokova i džepova.", + "help.sketch.scale": "Skalira krive i tačke skice faktorom oko bazne tačke. Koristite ga da promijenite veličinu dijela skice bez ponovnog crtanja.", + "help.sketch.slot.arc_center": "Crta lučni žlijeb zadate širine duž luka određenog centrom, početkom i krajem. Koristite ga za žlijebove za podešavanje oko osovine.", + "help.sketch.slot.arc_three_point": "Crta lučni žlijeb zadate širine duž luka kroz tri tačke. Koristite ga kada putanja žlijeba mora proći kroz zadate tačke.", + "help.sketch.slot.center_point": "Crta ravni žlijeb zadate širine od njegovog centra do centra jednog krajnjeg luka. Koristite ga za žlijebove centrirane na tački.", + "help.sketch.slot.center_to_center": "Crta ravni žlijeb zadate širine između centara njegova dva krajnja luka. Koristite ga za žlijebove kotirane od centra do centra, npr. žlijebove za podešavanje vijaka.", + "help.sketch.slot.overall": "Crta ravni žlijeb zadate širine od jednog vanjskog kraja do drugog. Koristite ga kada je bitna ukupna dužina žlijeba.", + "help.sketch.spline.control_point": "Crta kubni splajn oblikovan kontrolnim tačkama kroz koje ne prolazi. Koristite ga za glatke, tečne krive koje oblikujete pomjeranjem tačaka.", + "help.sketch.spline.control_point_5": "Crta splajn petog stepena oblikovan kontrolnim tačkama. Koristite ga tamo gdje se krive moraju spajati glađom zakrivljenošću, npr. na vidljivim dizajnerskim površima.", + "help.sketch.spline.fit_point": "Crta glatki splajn koji prolazi kroz svaku tačku koju postavite, otvoren ili zatvoren. Koristite ga da oblik provučete kroz poznate tačke.", + "help.sketch.spline3d": "Crta glatku krivu u prostoru kroz tačke, uz uključenu 3D skicu. Koristite ga za putanje povlačenja i vođenje kablova koji izlaze iz ravni skice.", + "help.sketch.spun_profile": "Pravi u ravni skice obris koji tijelo opisuje kada se okreće oko ose. Koristite ga da dobijete siluetu tokarenog dijela za rotiranje ili crtež.", + "help.sketch.text": "Upisuje tekst u skicu izabranim fontom, visinom i uglom; njegove riječi mogu pratiti parametre. Ekstrudirajte ga ili ga napravite reljefom za etikete i natpise.", + "help.sketch.transform": "Skalira, okreće i pomjera geometriju skice oko tačke centra u jednom koraku. Koristite ga da istovremeno promijenite položaj i veličinu izbora.", + "help.sketch.trim": "Uklanja dio krive između najbližih presjeka oko mjesta na koje kliknete. Koristite ga da preklapajuće linije očistite u zatvoren profil.", "help.solid.align": "Pomjera izabrana tijela tako da se tačka, lice ili ravan na njima poklopi s tačkom, licem ili ravni negdje drugdje. Koristite ga da postavite dio uz drugi bez mjerenja odmaka.", + "help.solid.boundary_fill": "Pravi puna tijela od područja zatvorenih preklapajućim alatnim tijelima, popunjavajući samo područja koja izaberete. Koristite ga da oblik izgradite iz prostora koji nekoliko tijela zajedno ograničava.", + "help.solid.bounding_solid": "Pravi kutiju koja obuhvata izabrana tijela, s neobaveznom marginom. Koristite ga da vidite ukupnu veličinu dijela ili kao pripremak iz kojeg se dio obrađuje.", + "help.solid.box": "Pravi kutiju iz dužine, širine i visine, iz tačke ugla ili centriranu na tački. Koristite je za brz blok, privremeni oblik ili tijelo u koje se urezuju operacije.", "help.solid.chamfer": "Odsijeca izabrane ivice ravnim skošenjem zadate udaljenosti. Koristite ga da ublažite oštre ivice ili olakšate uvođenje zatika ili vijka.", + "help.solid.coil": "Pravi zavojno tijelo, npr. oprugu, iz prečnika, broja zavoja, visine ili koraka i okruglog ili kvadratnog presjeka. Koristite je za opruge, zavojnice i zavojne grebene.", "help.solid.combine": "Spaja tijela, oduzima ih od ciljnog tijela ili zadržava samo zajedničku zapreminu, a može zadržati i alatna tijela. Koristite ga da spojite dijelove, da jednim oblikom odsiječete drugi ili da pronađete gdje se dva tijela preklapaju.", + "help.solid.cone": "Pravi konus iz kruga osnove i visine, ili zarubljeni konus kada i vrh ima poluprečnik. Koristite ga za suženja, lijevke, konusna upuštenja i šiljke.", + "help.solid.cylinder": "Pravi cilindar iz poluprečnika ili prečnika i visine, postavljen na tačku duž ose. Koristite ga za osovine, zatike, ispuste i okrugle pripremke.", "help.solid.draft": "Naginje izabrana lica za ugao koji zadate, mjereno od neutralne ravni. Koristite ga na livenim ili brizganim dijelovima kako bi izašli iz kalupa.", + "help.solid.emboss": "Izdiže profile skice, npr. tekst ili logotip, iz lica, ili ih udubljuje u njega. Koristite ga za natpise, etikete i oznake na ravnim ili okruglim licima.", "help.solid.extrude": "Izvlači profil skice u 3D duž zadate udaljenosti, dodajući ili uklanjajući materijal. Koristite ga da ravni obris pretvorite u blok, ispust ili džep.", "help.solid.fillet": "Zaobljuje izabrane ivice glatkim kružnim prelazom zadatog poluprečnika. Koristite ga da omekšate oštre ivice radi čvrstoće, udobnosti ili izgleda.", "help.solid.hole": "Buši okruglu rupu zadatog prečnika na izabranoj tački lica. Koristite je za prolaze vijaka, zatike i druge jednostavne okrugle otvore.", @@ -1425,20 +1637,60 @@ "help.solid.move": "Pomjera ili rotira izabrana tijela, ili njihovu kopiju, za udaljenost i ugao koje zadate. Koristite ga da premjestite tijelo ili napravite drugo na drugom mjestu.", "help.solid.offset_face": "Gura izabrana lica unutra ili van za udaljenost koju zadate, a okolna lica ih prate. Koristite ga da podebljate zid, produbite džep ili povisite ispupčenje bez uređivanja skice.", "help.solid.pattern.circular": "Ponavlja izabrane objekte oko ose, za zadati broj i ukupni ugao. Koristite ga za krugove vijaka, žbice, rebra hladnjaka i zupce.", + "help.solid.pattern.path": "Kopira operacije ili tijela duž putanje skice, prema broju i razmaku. Koristite ga za rupe duž zakrivljene ivice ili dijelove duž vodilice.", "help.solid.pattern.rectangular": "Ponavlja izabrane objekte u jednom ili dva smjera sa zadatim brojem i razmakom. Koristite ga za redove i mreže rupa, rebara, zatika ili nožica.", + "help.solid.pipe": "Pravi okruglu cijev duž putanje od kriva skice, punu ili šuplju sa zadatom debljinom zida. Koristite je za cijevi, žice, kablove i šine.", "help.solid.press_pull": "Dodaje ili uklanja materijal dok povlačite lice, ili zaobljava ivice dok ih povlačite. Koristite ga za brze izmjene direktno na modelu.", "help.solid.remove": "Uklanja izabrana tijela iz dizajna od ove tačke vremenske trake nadalje; raniji koraci ih zadržavaju. Koristite ga da odbacite pomoćno tijelo, npr. alat za rezanje koji vam više ne treba.", "help.solid.replace_face": "Premješta izabrana ravna lica na ravan ili na paralelno lice, a tijelo raste ili se smanjuje da ga dosegne. Koristite ga da poravnate lice s drugim dijelom ili pomoćnom ravni.", "help.solid.revolve": "Okreće profil skice oko ose i pravi okruglo tijelo. Koristite ga za vratila, dugmad, posude i svaki dio koji je okrugao oko središnje linije.", + "help.solid.rib": "Dodaje tanak zid duž otvorene krive skice, popunjen do sljedećih lica tijela. Koristite ga da ojačate zidove i uglove, npr. u brizganim ili štampanim dijelovima.", "help.solid.scale": "Povećava ili smanjuje izabrana tijela za faktor, jednako u svim smjerovima ili posebno po osi. Koristite ga da promijenite veličinu uvezenog oblika ili uračunate skupljanje.", "help.solid.shell": "Izdubljuje tijelo i ostavlja zidove zadate debljine, a može otvoriti lica koja izaberete. Koristite ga za kutije, poklopce i druge tankozidne dijelove.", + "help.solid.sphere": "Pravi sferu iz poluprečnika ili prečnika oko tačke centra. Koristite je za kugle, ručke i zaobljene krajeve.", "help.solid.split_body": "Reže tijelo na zasebna tijela ravni, licem drugog tijela ili linijom skice. Koristite ga za dijelove koji se zasebno štampaju, obrađuju ili boje.", "help.solid.split_face": "Dijeli izabrana lica po ravni, licu drugog tijela ili krivama skice, bez rezanja tijela. Koristite ga da dijelu lica date vlastiti nagib, boju ili odmak.", "help.solid.sweep": "Povlači profil skice duž putanje i pravi tijelo. Koristite ga za cijevi, šine, ručke i svaki dio konstantnog presjeka koji prati krivu.", + "help.solid.thread": "Dodaje ISO metrički navoj na cilindrično lice, kao kozmetički navoj ili urezan u tijelo. Koristite ga za vijke, navojne rupe i štampane dijelove s navojem.", + "help.solid.torus": "Pravi prsten iz velikog poluprečnika oko njegove ose i malog poluprečnika za okrugli presjek. Koristite ga za O-prstenove, ručke i okrugle perle.", + "help.solid.web": "Dodaje tanak zid duž svake otvorene krive skice, svaki popunjen do sljedećih lica tijela. Koristite ga za mrežu potpornih zidova unutar kućišta i posuda.", + "help.surface.blend": "Pravi površ koja premošćuje ivice dvaju tijela, tangentnu ili kontinuiranu po zakrivljenosti s oba. Koristite je da dvije površi glatko spojite preko razmaka.", + "help.surface.boundary": "Pravi površ koja ispunjava petlju od dvije do četiri krive skice, ili mrežu kriva. Koristite je za slobodne panele određene svojim ivicama.", + "help.surface.extend": "Produžava površ preko njenih ivica, za neku udaljenost ili do ravni ili lica. Koristite ga da se površi preklope prije nego što ih odsiječete ili sašijete.", + "help.surface.extrude": "Izvlači krive ili profile skice na zadatu udaljenost u površ, samo zidove, bez poklopaca. Koristite ga za tanke zidove i za alate za rezanje ili razdvajanje.", + "help.surface.fair": "Izglađuje unutrašnjost slobodnih lica dok njihove ivice ostaju na mjestu. Koristite ga da izravnate izbočine i valovite refleksije u površi.", + "help.surface.loft": "Pravi površ koja redom prolazi kroz dva ili više presjeka, bez poklopaca. Koristite je za ljuske i prelaze između različitih oblika.", + "help.surface.match": "Ponovo gradi ivicu površi tako da se sastaje s ivicom drugog tijela, uz podudarnu tangentu ili zakrivljenost. Koristite ga da glatko zatvorite šav između dvije površi.", + "help.surface.offset": "Pravi kopiju lica na udaljenosti duž njihovih normala, kao novo tijelo površi. Koristite ga za omotače, zazore ili drugu stranu zida.", + "help.surface.patch": "Ispunjava područje skice ili zatvorenu petlju ivica licem, ravnim ili glatko zakrivljenim. Koristite ga da zatvorite rupe u tijelu površi.", + "help.surface.reverse_normal": "Okreće lica tijela površi, pa njihova vanjska strana gleda na drugu stranu. Koristite ga prije podebljanja ili šivenja kada lica gledaju prema unutra.", + "help.surface.revolve": "Okreće krive ili profile skice oko ose u površ, bez poklopaca. Koristite ga za okrugle ljuske poput abažura, zdjela i mlaznica.", + "help.surface.ruled": "Pravi traku pravih linija iz lanca ivica, duž normale lica, tangente ili pod uglom. Koristite ga za zidove s nagibom, odvodne površi i površi razdvajanja.", + "help.surface.stitch": "Šije tijela površi zajedno duž njihovih zajedničkih ivica, u puno tijelo kada se zatvore. Koristite ga da skup površi pretvorite u jedno tijelo.", + "help.surface.sweep": "Pomjera krive ili profile skice duž putanje u površ, bez poklopaca. Koristite ga za žljebove, lajsne i druge tanke oblike koji prate putanju.", + "help.surface.thicken": "Daje tijelu površi debljinu i pretvara ga u puno tijelo. Koristite ga da od površi napravite dijelove pogodne za štampu ili obradu.", + "help.surface.trim": "Siječe površ ravni, drugom površi ili krivama skice i zadržava stranu koju izaberete. Koristite ga da površi oblikujete prije šivenja.", + "help.surface.unstitch": "Razdvaja lica tijela u zasebna tijela površi; puno tijelo se raspada na površi. Koristite ga da preradite pojedinačna lica tijela.", + "help.surface.untrim": "Uklanja rupe iz lica, ili vraća njegovu vanjsku granicu na prirodne granice površi. Koristite ga da nakon odsijecanja vratite cijelu površ.", "help.third_party": "Licence trećih strana", "help.timeline.compute_all": "Ponovo gradi svaku operaciju na vremenskoj traci od početka. Koristite ga da osvježite model i vidite koje operacije ne uspijevaju.", "help.timeline.delete": "Briše izabrane operacije s vremenske trake; brisanje skice briše i operacije napravljene iz nje. Koristite ga da uklonite korak koji više ne želite.", + "help.timeline.dependents": "Navodi operacije koje bi bile izbrisane zajedno s nekom operacijom i one koje bi bez nje otkazale. Koristite ga prije brisanja ili mijenjanja operacije.", "help.timeline.play": "Reprodukuje preostale operacije jednu po jednu, s pauzom od pola sekunde nakon svake ponovne izgradnje. Pokrenite ovu komandu ponovo ili pritisnite Escape da zaustavite; Idi na kraj skače odmah.", + "help.view.apply": "Okreće prikaz u standardni smjer, npr. sprijeda, odozgo ili izometrijski, ili u sačuvani imenovani prikaz. Koristite ga da model posmatrate iz utvrđenog ugla.", + "help.view.delete": "Briše sačuvani imenovani prikaz. Model se ne mijenja.", + "help.view.fit": "Zumira i pomjera prikaz tako da cijeli model, ili izbor, ispuni prikaz. Koristite ga kada model nestane iz vidokruga.", + "help.view.grid": "Prikazuje ili sakriva mrežu u prozoru prikaza. Koristite je kao vodič za veličinu i orijentaciju tokom modeliranja.", + "help.view.ground_shadow": "Prikazuje ili sakriva sjenu modela na podlozi. Koristite je da lakše procijenite visinu i položaj.", + "help.view.home": "Vraća prikaz na početni smjer i uklapa model. Koristite ga da se brzo vratite na poznat prikaz.", + "help.view.list": "Navodi imenovane prikaze sačuvane u dizajnu. Koristite ga da pronađete prikaz koji ste ranije sačuvali.", + "help.view.navigate": "Okreće, pomjera ili zumira prikaz u koracima, npr. četvrtinu okreta ulijevo. Koristite ga da kameru pomjerate precizno umjesto povlačenjem.", + "help.view.navigation_bar": "Prikazuje ili sakriva navigacijsku traku na dnu prozora prikaza. Sakrijte je za više prostora, prikažite je da alatima prikaza pristupate mišem.", + "help.view.rename": "Daje sačuvanom imenovanom prikazu novi naziv. Sam prikaz ostaje isti.", + "help.view.save": "Čuva trenutni položaj kamere kao imenovani prikaz u dizajnu. Koristite ga da se kasnije vratite na isti ugao, npr. za snimke ekrana.", + "help.view.spin": "Neprekidno orbitira prikaz oko modela do sljedećeg klika ili tipke. Koristite ga da model predstavite sa svih strana.", + "help.view.tool": "Zaključava alat kamere, orbitiranje, pomjeranje ili zumiranje, tako da povlačenje lijevim dugmetom miša pomjera prikaz dok ga ne isključite. Koristite ga na touchpadu ili bez srednjeg dugmeta miša.", + "help.view.visual_style": "Mijenja način crtanja modela: osjenčeno sa ili bez ivica, žičani model ili sa skrivenim ivicama. Koristite žičani model da vidite unutra, a osjenčeno za čist izgled.", "history.sketch_under_constrained": "Nedovoljno ograničeno (stepeni slobode: {count})", "history.sketch_under_constrained_one": "Nedovoljno ograničeno ({count} stepen slobode)", "hole_presets.confirm_delete": "Potvrdi brisanje", @@ -1615,6 +1867,10 @@ "material.selection.mixed": "Različiti materijali", "measure.at": "Na", "measure.item": "Stavka {number}", + "measure.quick_copied": "Mjerenje kopirano", + "measure.quick_copy": "Kliknite da kopirate mjerenje", + "measure.quick_edges": "Ivice: {count}", + "measure.quick_faces": "Lica: {count}", "messages.ago_h": "prije {n} h", "messages.ago_min": "prije {n} min", "messages.ago_s": "prije {n} s", @@ -1767,6 +2023,11 @@ "not a move": "nije pomjeranje", "not a number": "nije broj", "open a sketch first": "prvo otvorite skicu", + "parameters.apply_live": "Primijeni svaku promjenu kad napustite njeno polje", + "parameters.apply_live_tip": "Isključeno: promjene čekaju na Primijeni ili U redu, a zatim se dizajn jednom ponovo gradi za sve njih. Otkaži ili Escape ih odbacuje.", + "parameters.held": "Promjene na čekanju za Primijeni: {count}", + "parameters.held_hint": "Uređujte izraze i jedinice. Promjene čekaju na Primijeni ili U redu.", + "parameters.held_one": "1 promjena na čekanju za Primijeni", "parts.automatic_name": "automatski", "parts.dimension.across_flats": "Širina ključa", "parts.dimension.bore": "Provrt", @@ -1789,6 +2050,8 @@ "parts.family.set_screw": "Bezglavi vijak s imbusom, ravni vrh", "parts.family.socket_head_cap_screw": "Vijak s cilindričnom glavom i imbusom", "parts.family.washer": "Obična podloška", + "parts.holes": "Rupe (neobavezno)", + "parts.placement_hint": "izaberite jednu ili više rupa da u svaku postavite dio, ili ga U redu postavlja u ishodište", "pattern.alignment_2": "Poravnanje 2", "pattern.alignment_hint": "Broj uključuje original. Centar ravnomjerno raspoređuje kopije; Obrni bira dodatnu stranu za parne ukupne brojeve.", "pattern.direction_1": "Smjer 1", @@ -1955,6 +2218,8 @@ "prefs.origin_plane_names": "Nazivi ravni ishodišta", "prefs.origin_plane_names_note": "Nazivi prikazani u stablu modela, dijalozima i listama. {top} je XY, {front} je XZ, a {right} je YZ, sa Z prema gore. Komande, skripte i sačuvani dizajni zadržavaju XY, XZ i YZ.", "prefs.overall_speed": "Ukupna brzina", + "prefs.parameter_list_rows": "Redovi popisa parametara", + "prefs.parameter_list_rows_tip": "Strelica prema dolje u praznom polju vrijednosti navodi parametre, favorite prvo, do ovog broja. Manji broj održava popis laganim u velikom dizajnu.", "prefs.persistent_ribbon_pressed": "Istakni aktivne komande na traci", "prefs.persistent_ribbon_pressed_help": "Ističe komande na traci dok su njihovi efekti uključeni. Isključite da biste zadržali prethodni neutralni izgled i pomoć za komande.", "prefs.perspective_fov": "Vidno polje", @@ -1984,6 +2249,8 @@ "prefs.quality_fastest": "najbrže", "prefs.quality_slowest": "najsporije", "prefs.quality_smoother": "glađe", + "prefs.quick_measure": "Brzo mjerenje u uglu prikaza", + "prefs.quick_measure_note": "Veličina izbora u donjem desnom uglu prikaza: dužina ivice, ili Ø i R za okruglu, površina lica, položaj tačke, udaljenost i ugao dviju stavki, ukupna vrijednost više ivica ili lica. Klik je kopira.", "prefs.render_previous_geometry_budget": "Koristi prethodni budžet geometrije za pregled renderovanja", "prefs.render_previous_geometry_budget_help": "Vraća prethodno fiksno ograničenje geometrije (512 MiB na 64-bitnim desktop računarima, 128 MiB drugdje) umjesto budžeta prema uređaju.", "prefs.reports_saved": "Sačuvano: {n}", @@ -2029,6 +2296,8 @@ "prefs.sketch_tab_always_note": "Kartica Skica ostaje na traci, odmah iza Modela, dok nijedna skica nije otvorena. Njeni alati za crtanje tada pokreću skicu na izabranoj ravni ili licu, ili na ravni koju sljedeću izaberete, s alatom spremnim u njoj.", "prefs.sketch_tracking_guides": "Pomoćne linije poravnanja centra i središnje tačke", "prefs.sketch_tracking_guides_note": "Zadržite pokazivač iznad centra kruga ili središnje tačke prave ivice, pa se pomjerite vodoravno ili okomito da se prikaže privremena tačkasta pomoćna linija.", + "prefs.sketch_tracking_points": "Pomoćne linije poravnanja iz krajnjih tačaka i uglova tokom crtanja", + "prefs.sketch_tracking_points_note": "Dok alat za crtanje čeka tačku, prelazak mišem preko kraja linije ili luka, ugla pravougaonika ili slobodne tačke prikazuje istu privremenu tačkastu pomoćnu liniju kao centar ili središnja tačka. Isključeno: samo centri i središnje tačke.", "prefs.sketching": "Skiciranje", "prefs.snap_drawing_note": "Tokom crtanja ili povlačenja tačke skice.", "prefs.source": "Izvor", @@ -2056,6 +2325,16 @@ "prefs.theme_follow_off": "Isključeno", "prefs.theme_follow_stop": "Prestani pratiti", "prefs.theme_import_hint": "Kliknite da izaberete datoteku teme, ispustite je ovdje ili pritisnite Ctrl+V (Cmd+V na Macu) kada je tema kopirana", + "prefs.theme_option_opacity": "Neprozirnost", + "prefs.theme_option_opacity_help": "Podesite neprozirnost plutajućih panela. Donja granica čitljivosti štiti tekst preko boja prozora prikaza.", + "prefs.theme_option_tab_colours": "Boje kartica", + "prefs.theme_option_tab_colours_help": "Koristi boje panela i prozora prikaza ove teme za svaku karticu trake.", + "prefs.theme_option_tab_fade_ms": "Prelaz kartica", + "prefs.theme_option_tab_fade_ms_help": "Boje panela i prozora prikaza postepeno se mijenjaju pri prelasku na drugu karticu. Nula mijenja odmah.", + "prefs.theme_options_badge": "Opcije", + "prefs.theme_options_more": "Više · {count}", + "prefs.theme_options_reset": "Resetuj", + "prefs.theme_options_unavailable": "Nedostupno na ovom uređaju", "prefs.theme_paste": "Zalijepi temu", "prefs.theme_paste_none": "Međuspremnik ne sadrži SolidCraft temu", "prefs.timeline_half_hour": "−30 min", @@ -2077,6 +2356,8 @@ "prefs.under_cursor": "Ispod pokazivača", "prefs.units_precision": "Jedinice i preciznost", "prefs.updates": "Ažuriranja", + "prefs.value_boxes_in_view": "Prikaži vrijednosti pored njihovih ručica u prikazu", + "prefs.value_boxes_in_view_tip": "Vrijednost komande (Udaljenost, Poluprečnik…) u polju pored njene ručice, preko modela. Isključeno: upisujete je u prozoru komande, koji preuzima tastaturu nakon izbora, i ništa ne prekriva model.", "prefs.view": "Ponašanje prikaza", "prefs.view_confirm_corner": "U redu i Otkaži u uglu prikaza", "prefs.view_confirm_corner_note": "Dok je otvoren dijalog ili skica, kvačica i krstić gore desno u prikazu rade isto što i dugmad U redu i Otkaži u dijalogu, odnosno završavaju i otkazuju skicu. Navigator prikaza pomjera se ispod njih.", @@ -2086,6 +2367,12 @@ "prefs.viewport_background": "Pozadina", "prefs.viewport_background_colour": "Boja", "prefs.viewport_background_theme": "Gradijent teme", + "prefs.viewport_section_status": "Prikaži status presjeka u prozoru prikaza", + "prefs.viewport_section_status_help": "Prikazuje postavku presjeka u prozoru prikaza. Escape i aktivno dugme na traci je isključuju. Isključite da zadržite prethodne kontrole presjeka.", + "prefs.viewport_status_details": "Detalji prozora prikaza uživo", + "prefs.viewport_status_details_help": "Uređujte postavke aktivne analize i presjeka u standardnim karticama s detaljima. Isključite da vratite legende analize samo za čitanje i dijalog Presjek.", + "prefs.viewport_status_summary": "Sažmi stavke statusa prozora prikaza", + "prefs.viewport_status_summary_help": "Sažima tri ili više stavki statusa prozora prikaza u jednu sažetu oznaku. Isključite da svaka stavka ostane u punom redu.", "prefs.wheel": "Točkić", "prefs.wheel_trackpad": "Točkić i trackpad", "prefs.workspace": "Radni prostor", @@ -2964,7 +3251,9 @@ "render.output_ready": "Spremno za renderovanje", "render.output_run_check": "Pokreni provjeru", "render.output_size_limit": "{method}: do {dimension} px po strani i {pixels} piksela. Najveća veličina koja staje: {width} × {height}.", + "render.output_size_method_profile": "Za renderovanje {width} × {height} izaberite {method} s CPU profilom resursa {profile} ili manju veličinu.", "render.output_size_ok": "Veličina u redu · {width} × {height} px", + "render.output_size_profile": "Za renderovanje {width} × {height} izaberite CPU profil resursa {profile} ili manju veličinu.", "render.output_swap_orientation": "Zamijeni širinu i visinu", "render.output_tools_inspector": "Veličinu i format uredite u inspektoru Izlaz.", "render.output_view_image": "Prikaži sliku", @@ -3177,6 +3466,7 @@ "render.size_custom": "Prilagođeno…", "render.size_full_hd": "Full HD", "render.size_hd": "HD", + "render.size_needs_profile": "{size} · potrebno: {profile}", "render.size_presets": "Predlošci veličine", "render.size_presets_pending": "Stiže s uređivačem izlaza", "render.size_preview": "Pregled", @@ -3464,6 +3754,24 @@ "sheet.flange_type_contour": "Kontura", "sheet.flange_type_edge": "Ivica", "sheet.no_body": "nema limenog tijela: napravite ga pomoću Prirubnica > Osnova ili pretvorite ploču u lim pomoću Pretvori u lim (izaberite njeno veliko ravno lice)", + "sheet.personal_rules.confirm_delete": "Potvrdi brisanje", + "sheet.personal_rules.delete": "Izbriši", + "sheet.personal_rules.delete_prompt": "Izbrisati ovo lično pravilo? Pravila već u dizajnima ostaju nepromijenjena.", + "sheet.personal_rules.full": "Lična biblioteka već sadrži 64 pravila.", + "sheet.personal_rules.invalid_name": "Unesite kratak naziv bez prijeloma reda ili kontrolnih znakova.", + "sheet.personal_rules.invalid_values": "Dopunite vrijednosti pravila: svaka se mora izračunati u ovom dizajnu, s pozitivnom debljinom i K-faktorom od 0 do 1.", + "sheet.personal_rules.missing": "Izabrano lično pravilo više nije dostupno.", + "sheet.personal_rules.name": "Naziv pravila", + "sheet.personal_rules.name_exists": "Lično pravilo s ovim nazivom već postoji. Koristite drugi naziv.", + "sheet.personal_rules.name_hint": "Jedinstven naziv", + "sheet.personal_rules.overwrite": "Sačuvaj promjene u izabranom ličnom pravilu", + "sheet.personal_rules.rename": "Preimenuj", + "sheet.personal_rules.rule": "Lično pravilo", + "sheet.personal_rules.save": "Sačuvaj novo", + "sheet.personal_rules.scope": "Čuva se u vašem profilu za sve dizajne.", + "sheet.personal_rules.select": "Izaberite pravilo", + "sheet.personal_rules.title": "Lična pravila", + "sheet.personal_rules.unsupported": "Ovo pravilo ima vrijednosti koje ova verzija ne može koristiti.", "shortcut.json_error": "parametri moraju biti JSON: {error}", "shortcut.no_command": "nema komande `{id}`; tekst prije JSON-a mora biti id komande", "shortcut.pin": "Prikači na vrh S okvira", @@ -3500,6 +3808,7 @@ "sketch_text.align_right": "Desno", "sketch_text.bold": "Podebljano", "sketch_text.builtin_font": "{family} (ugrađeno)", + "sketch_text.formula": "Počnite znakom = da riječi složite iz parametara: ='W' + number(Width)", "sketch_text.italic": "Kurziv", "sketch_text.loading_fonts": "Traženje instaliranih fontova…", "sketch_text.on_path": "Tekst prati svoju putanju: kriva ga postavlja", @@ -3640,6 +3949,8 @@ "ui.3d_mouse_section": "3D miš", "ui.3d_mouse_show_in_view": "Prikaži 3D miš u 3D prikazu", "ui.3d_mouse_show_in_view_tip": "Slika vašeg 3D miša u donjem lijevom uglu, koja se kreće dok ga pomjerate.", + "ui.3d_mouse_sketch_orbit": "Okreći skice 3D mišem", + "ui.3d_mouse_sketch_orbit_tip": "3D miš okreće prikaz i dok je skica otvorena i gledana pod pravim uglom, kao što to može i miš. Isključeno: pomjera i zumira skicu dok ne nagnete prikaz.", "ui.3d_mouse_skip": "Moj uređaj ovo ne može", "ui.3d_mouse_skip_tip": "Preskače ovo kretanje u oba smjera, a kalibracija ga izostavlja.", "ui.3d_mouse_speed": "Brzina 3D miša", @@ -3708,6 +4019,7 @@ "ui.agent_chat_auth_terminal": "Pokrenite ovo u terminalu, a zatim pritisnite Poveži ponovo:", "ui.agent_chat_auth_title": "Prijavi se kod agenta", "ui.agent_chat_cancel": "Otkaži", + "ui.agent_chat_cannot_start": "Instalirano, ali se ne pokreće: {error}", "ui.agent_chat_changes": "Promjene: {n}, jedan korak poništavanja", "ui.agent_chat_changes_n": "Promjene: {n}", "ui.agent_chat_changes_of": "Promjene: {n}/{limit}", @@ -3800,9 +4112,12 @@ "ui.agent_chat_queued_stopped": "U redu čekanja (zaustavljeno, nije poslano)", "ui.agent_chat_refresh": "Osvježi", "ui.agent_chat_refused": "Nije dozvoljeno agentu: {reason}", + "ui.agent_chat_reinstall": "{agent} je instaliran ({program}), ali se nije pokrenuo kada ga je SolidCraft provjerio: {error}\nNjegova instalacija možda nije potpuna ili je napravljena za drugu vrstu računara. Instalirajte ga ponovo naredbom `{install}`, provjerite da li u terminalu ispisuje svoju verziju, a zatim pritisnite Osvježi.", "ui.agent_chat_remove": "Ukloni iz reda", "ui.agent_chat_remove_file": "Skini s ove poruke", "ui.agent_chat_remove_imported": "Skini s ove poruke; dio ostaje u dizajnu (Poništi ga uklanja)", + "ui.agent_chat_reply_cut_off": "Odgovor je dostigao ograničenje dužine prije nego što je završen, pa može biti nepotpun. Zamolite agenta da nastavi ili da da kraći odgovor.", + "ui.agent_chat_reply_declined": "Model je odbio odgovoriti na ovu poruku.", "ui.agent_chat_retry": "Pokušaj ponovo", "ui.agent_chat_right": "Desno", "ui.agent_chat_save": "Sačuvaj", @@ -3861,12 +4176,17 @@ "ui.ai_copy_suffix": "kopiraj", "ui.ai_custom_url": "Prilagođeni URL", "ui.ai_default": "Podrazumijevano", + "ui.ai_edit": "Uredi", + "ui.ai_edit_agent_title": "Uredi agenta", + "ui.ai_edit_gone": "Ovaj agent je uklonjen, pa nema šta da se sačuva.", + "ui.ai_edit_tip": "Promijenite naziv, komandu ili krajnju tačku ovog agenta. Sačuvani ključ ostaje skriven.", "ui.ai_http_busy": "krajnja tačka je zauzeta ili je preko ograničenja; pokušajte ponovo kasnije", "ui.ai_http_key": "krajnja tačka je odbila ključ: provjerite ga u Postavke > AI", "ui.ai_http_not_found": "nema takve krajnje tačke ili modela: provjerite osnovni URL i naziv modela", "ui.ai_http_too_large": "razgovor je prevelik za ovu krajnju tačku; započnite novi razgovor", "ui.ai_key": "API ključ", "ui.ai_key_hint": "neobavezno; čuva se u sistemskom privjesku ključeva", + "ui.ai_key_keep_hint": "ostavite prazno da zadržite sačuvani ključ", "ui.ai_key_needs_https": "Ključ se šalje samo preko HTTPS-a ili na ovaj računar.", "ui.ai_key_note": "Čuva se u sistemskom privjesku ključeva, nikad u postavkama SolidCrafta. Za lokalni server ostavite prazno.", "ui.ai_keychain_error": "Sistemski privjesak ključeva je odbio: {error}", @@ -3898,6 +4218,7 @@ "ui.ai_request_timeout": "Vremensko ograničenje zahtjeva", "ui.ai_request_timeout_hint": "Koliko dugo jedan zahtjev modelu na ovoj krajnjoj tački smije trajati, od 30 s do jednog sata. Povećajte za spori lokalni model.", "ui.ai_reset": "Resetuj", + "ui.ai_save": "Sačuvaj", "ui.ai_settings": "AI postavke…", "ui.ai_show_features": "Prikaži AI funkcije", "ui.ai_show_features_note": "Razgovor s agentom: panel u kojem agent za kodiranje koji već koristite ili krajnja tačka modela koju izaberete radi na otvorenom dizajnu. Isključeno: nema AI dugmeta, prečice, stavke menija ni panela, i ništa se ne traži niti pokreće.", @@ -4071,6 +4392,40 @@ "ui.design_properties.project": "Projekat", "ui.design_properties.revision": "Revizija", "ui.design_saved": "Sačuvano: {path}", + "ui.details_access_caption": "Lica pokazuju može li alat doći do njih iz izabranog smjera.", + "ui.details_angle": "Ugao", + "ui.details_continuity_caption": "Uredite izabrani šav. Ostali šavovi zadržavaju vlastite postavke.", + "ui.details_continuity_stale": "Ranija geometrija: ponovo pokrenite analizu da uredite ili uokvirite ovaj šav.", + "ui.details_copy_list": "Kopiraj popis", + "ui.details_curvature": "Tolerancija zakrivljenosti", + "ui.details_curvature_caption": "Lica su obojena prema svom lokalnom poluprečniku zakrivljenosti.", + "ui.details_direction": "Smjer alata", + "ui.details_draft_caption": "Lica su obojena prema svom uglu u odnosu na smjer vađenja iz kalupa.", + "ui.details_edit_seam": "Uredi šav", + "ui.details_fixture": "Uzorak komponente — samo probni podaci", + "ui.details_fixture_caption": "Uzorak komponente s ilustrativnim vrijednostima; ovo nije rezultat modela.", + "ui.details_fixture_problem": "Primjer nalaza s problemom", + "ui.details_fixture_readout": "Primjer izmjerene vrijednosti", + "ui.details_flip": "Obrni", + "ui.details_gap": "Otvoreni kraj", + "ui.details_hatch": "Šrafiraj presječena lica", + "ui.details_jump": "Skok zakrivljenosti", + "ui.details_key": "Legenda", + "ui.details_offset": "Odmak", + "ui.details_plane": "Ravan", + "ui.details_pull": "Smjer vađenja iz kalupa", + "ui.details_radius": "Poluprečnik", + "ui.details_readout": "Očitanje", + "ui.details_samples": "Uzorci", + "ui.details_seam": "{body} · Ivica {edge}", + "ui.details_settings": "Postavke", + "ui.details_sharp_gap": "Oštro ili otvoreno", + "ui.details_show_all": "Prikaži svih {count}", + "ui.details_smooth": "Glatko", + "ui.details_stripes": "Pruge", + "ui.details_target": "Uokviri geometriju", + "ui.details_worst_seams": "Najgori šavovi", + "ui.details_zebra_caption": "Reflektovane pruge pokazuju koliko se glatko lica spajaju.", "ui.diameter": "Prečnik", "ui.dimension": "Kota", "ui.direct_interaction": "Direktna interakcija", @@ -4131,6 +4486,7 @@ "ui.equation_y_of_x": "y(x)", "ui.equivalent_radius": "Ekvivalentni poluprečnik", "ui.expand": "Proširi", + "ui.explode_needs_two_components": "Rastavljeni prikaz razdvaja komponente, pa dizajn treba imati dvije ili više njih (Nova komponenta, Umetni dizajn ili Napravi komponente iz tijela)", "ui.exportDxf": "Izvezi DXF…", "ui.export_ellipsis_keymap": "Izvezi…", "ui.export_keymap_tip": "Tipke, favoriti okvira S i traka s alatima, kao datoteka", @@ -4419,12 +4775,17 @@ "ui.plugins_skipped": "Foldere nije bilo moguće pročitati: {count}.", "ui.plugins_version": "{version}", "ui.point": "Tačka", + "ui.point_cloud_add_filter": "Dodaj filter…", "ui.point_cloud_auto_range": "Automatski opseg", "ui.point_cloud_color_by": "Boja prema", "ui.point_cloud_file_colors": "Boje iz datoteke", + "ui.point_cloud_filter_by": "Filtriraj prema", "ui.point_cloud_height_high": "Najviše", "ui.point_cloud_height_low": "Najniže", + "ui.point_cloud_legend_filter": "Prikazano i skriveno u legendi iznad", "ui.point_cloud_no_file_colors": "Datoteka nema boje", + "ui.point_cloud_pause": "Pauziraj", + "ui.point_cloud_play": "Reprodukuj", "ui.point_cloud_single_color": "Jedna boja", "ui.point_cloud_size": "Veličina tačke", "ui.point_cloud_units": "Jedinice datoteke", @@ -4453,6 +4814,7 @@ "ui.properties.inertia_y": "Iyx, Iyy, Iyz", "ui.properties.inertia_z": "Izx, Izy, Izz", "ui.properties.measuring": "Mjerenje…", + "ui.properties.measuring_bodies": "Mjerenje… {done} od {total} tijela", "ui.properties.measuring_failed": "Mjerenje je zaustavljeno prije završetka.", "ui.properties.principal_axis": "Glavna osa {number}", "ui.properties.principal_moments": "Glavni momenti", @@ -4520,6 +4882,7 @@ "ui.selectOther": "Izaberi drugo…", "ui.selectTangentChain": "Izaberi tangentni lanac", "ui.select_all": "Izaberi sve", + "ui.select_components_by_face": "Izaberi komponente (lice bira svoju komponentu)", "ui.selection": "Izbor", "ui.selection_modifiers": "Modifikatori izbora", "ui.selection_shift_add_only": "Shift+klik dodaje bez uklanjanja", @@ -4561,6 +4924,11 @@ "ui.sketch_intersect_selection_filter": "Filter izbora", "ui.sketch_modifiers": "Modifikatori skice", "ui.sketch_project_geometry": "Geometrija", + "ui.sketch_report.all_constrained": "Sve je potpuno ograničeno.", + "ui.sketch_report.dof": "Stepeni slobode", + "ui.sketch_report.loose": "Nije potpuno ograničeno", + "ui.sketch_report.status": "Status", + "ui.sketch_report.title": "Izvještaj o skici", "ui.sketch_scale": "Skaliranje skice", "ui.sketches": "Skice", "ui.slice": "Presijeci", @@ -4670,7 +5038,14 @@ "ui.viewport_status_key": "Prikaži legendu: {name}", "ui.viewport_status_minimum_radius": "Minimalni poluprečnik", "ui.viewport_status_pairs": "Parovi: {count}", + "ui.viewport_status_section_hatch": "{setting} · šrafura", + "ui.viewport_status_section_plane": "Trenutna ravan", + "ui.viewport_status_section_tilted": "nagnuto · {offset}", "ui.viewport_status_stripes": "Pruge: {count}", + "ui.viewport_status_summary_clear": "Isključi sve", + "ui.viewport_status_summary_heading": "U ovom prikazu", + "ui.viewport_status_summary_label": "{name} i još stavki: {count}", + "ui.viewport_status_summary_more": "+{count}", "ui.viewport_theme_follow": "Prati temu", "ui.visual_shaded_hidden_edges": "Osjenčeno sa skrivenim ivicama", "ui.visual_shaded_with_edges": "Osjenčeno s ivicama", diff --git a/assets/locales/cs.json b/assets/locales/cs.json index 44a8eb078d..41503e4e63 100644 --- a/assets/locales/cs.json +++ b/assets/locales/cs.json @@ -60,6 +60,7 @@ "BLEND SURFACE": "PŘECHODOVÁ PLOCHA", "BOUNDARY SURFACE": "PLOCHA Z HRANIC", "BROWSER": "PROHLÍŽEČ", + "Back to the original order": "Zpět na původní pořadí", "Ball": "Kulový", "Bar": "Tyč", "Base Center": "Střed základny", @@ -595,6 +596,8 @@ "Snap to face and edge centres": "Přichytávat ke středům ploch a hran", "SolidCraft cannot submit reports directly. Submit on GitHub; no token is needed in the app.": "SolidCraft nemůže odesílat hlášení přímo. Odešlete je na GitHubu; v aplikaci není potřeba žádný token.", "SolidCraft closed before these designs were saved. Their last autosave can be opened.": "SolidCraft se zavřel dříve, než se tyto návrhy uložily. Lze otevřít jejich poslední automatické uložení.", + "Sort A to Z": "Seřadit od A do Z", + "Sort Z to A": "Seřadit od Z do A", "Spacing 2 must be a non-zero number": "Rozteč 2 musí být nenulové číslo", "Spacing must be a non-zero number": "Rozteč musí být nenulové číslo", "Specialise": "Specializovat", @@ -819,6 +822,7 @@ "cmd.appearance_pick_up": "Převzít vzhled", "cmd.appearance_replace": "Nahradit vzhled", "cmd.assembly_bom": "Kusovník", + "cmd.assembly_mass_properties": "Hmotnostní vlastnosti", "cmd.body.print_face": "Tisková plocha", "cmd.body_rename": "Přejmenovat těleso", "cmd.browser_collapse_all": "Sbalit vše", @@ -1244,6 +1248,7 @@ "cmd.view.render_browser": "Prohlížeč renderu", "cmd.view.render_dock": "Přesunout panel renderu", "cmd.view.render_focus": "Soustředěný pohled na render", + "cmd.view.status_details": "Podrobnosti stavu pohledu", "cmd.view.status_items": "Stavové položky pohledu", "cmd.view_ambient_occlusion": "Okolní zastínění", "cmd.view_angle_readout": "Úhly pohledu", @@ -1387,21 +1392,168 @@ "from the rule": "z pravidla", "help.apache": "Licence Apache 2.0", "help.appearance.assign": "Přiřadí vybraným tělesům, plochám nebo komponentám barvu nebo vzhled materiálu, aniž by se změnila jejich hmotnost. Hodí se k rozlišení dílů nebo k náhledu povrchové úpravy.", + "help.assembly.bom": "Vypíše díly návrhu nebo komponenty s jejich počty podle čísla dílu a může seznam uložit jako CSV nebo JSON. Použijte ho jako soupis dílů pro objednávání a montáž.", "help.attributions": "Uvedení autorství (ATTRIBUTION.md)", "help.build": "Verze {version} · commit {commit} · sestaveno {date}", + "help.canvas.decal": "Umístí obrázek na rovinnou plochu modelu jako nálepku. Hodí se k zobrazení štítku, loga nebo potištěného vzoru na dílu.", + "help.canvas.edit": "Změní plátno: jeho polohu, velikost, úhel, neprůhlednost, zrcadlení, viditelnost nebo název. Hodí se k zarovnání referenčního obrázku s náčrtem, který podle něj obkreslujete.", + "help.canvas.insert": "Umístí obrázek PNG nebo JPEG na rovinu nebo plochu jako průhlednou referenci. Hodí se k obkreslení fotografie nebo výkresu v náčrtu, přičemž obrázek má zadanou známou velikost.", + "help.component.break_link": "Odpojí propojenou komponentu od souboru návrhu a ponechá její tělesa beze změny. Použijte to, když už má díl přestat sledovat změny původního souboru.", + "help.component.capture_position": "Zachová polohy dílů, které jste přesunuli ručně, takže se stanou jejich novými polohami. Použijte to po přetažení dílů na místa, kde je žádný kloub nedrží.", + "help.component.create": "Přidá do návrhu novou prázdnou komponentu, a to do aktivní komponenty, pokud nezvolíte jinou nadřazenou. Použijte to, aby nový díl sestavy měl vlastní místo pro tělesa a náčrty.", "help.component.finish_edit": "Uloží díl upravovaný na místě, vrátí se do jeho sestavy a aktualizuje sestavu podle něj. Použijte ho, až jsou vaše změny propojeného dílu hotové.", + "help.component.from_bodies": "Změní každé vybrané těleso na samostatnou komponentu pojmenovanou podle tělesa. Použijte to, když se návrh vytvořený z volných těles má stát sestavou, jejíž díly lze spojovat a přesouvat.", + "help.component.insert_design": "Vloží jiný uložený návrh jako komponentu propojenou s jeho souborem, s tělesy umístěnými tak, jak jsou v něm. Použijte to k opětovnému použití dílu v sestavě a k převzetí pozdějších změn příkazem Aktualizovat odkazy.", + "help.component.properties": "Zobrazí objem, hmotnost, těžiště a momenty setrvačnosti komponenty podle materiálů jejích těles. Použijte to ke kontrole hmotnosti a vyvážení sestavy.", + "help.component.revert_position": "Vrátí ručně přesunuté díly na původní místo a zahodí přesuny, které nebyly zachyceny. Použijte to k vrácení zkušebních přesunů v sestavě.", + "help.component.update_links": "Znovu načte propojené soubory návrhů a nahradí tělesa, která se změnila. Použijte to po úpravě dílu, který tato sestava používá.", + "help.config.configure": "Přidá parametry, prvky, materiály nebo vzhledy jako sloupce tabulky konfigurací. Použijte to k vytvoření variant jednoho návrhu, například velikostí dílu.", + "help.config.preview": "Ověří, že se řádek tabulky konfigurací přestaví, aniž by změnil návrh nebo jeho historii. Použijte to před přepnutím na variantu, u které si nejste jisti.", + "help.config.rules": "Zobrazí pravidla tabulky konfigurací, její řádky a to, který řádek je aktivní. Použijte to, abyste viděli, jak jsou varianty návrhu nastaveny.", + "help.config.table": "Zobrazí tabulku konfigurací: jeden řádek na variantu, jeden sloupec na parametr nebo prvek a informaci, zda návrh stále odpovídá aktivnímu řádku. Použijte to ke kontrole variant návrhu.", "help.connect_note": "Restartujte každého asistenta, aby se načetl server SolidCraft. Každý soubor byl před změnou zálohován jako .bak.", "help.connect_title": "Připojit AI asistenty", + "help.construct.axis.edge": "Vytvoří konstrukční osu podél přímé hrany nebo úsečky náčrtu. Použijte ji jako osu pro rotaci, kruhové pole nebo kloub.", + "help.construct.axis.normal_to_face": "Vytvoří konstrukční osu procházející bodem a kolmou k ploše. Použijte ji jako osu, která vychází přímo z povrchu.", + "help.construct.axis.through_cylinder": "Vytvoří konstrukční osu středem válcové, kuželové nebo anuloidní plochy, nebo kruhové hrany. Použijte to k otáčení prvků nebo jejich rozmístění v poli kolem kulatého dílu.", + "help.construct.axis.two_planes": "Vytvoří konstrukční osu tam, kde se protínají dvě roviny nebo rovinné plochy. Použijte to, když je potřebná osa průsečnicí dvou ploch.", + "help.construct.axis.two_points": "Vytvoří konstrukční osu procházející dvěma body, vrcholy nebo body náčrtu. Použijte ji pro osu vedoucí mezi dvěma známými polohami.", + "help.construct.plane.along_path": "Vytvoří konstrukční rovinu kolmou k hraně nebo křivce v zadané poloze podél ní. Použijte to k náčrtu profilu pro tažení nebo příčného řezu podél cesty.", + "help.construct.plane.at_angle": "Vytvoří konstrukční rovinu natočenou o úhel od roviny nebo plochy kolem osy nebo hrany. Použijte ji pro šikmé náčrty, například u úhlového držáku.", + "help.construct.plane.midplane": "Vytvoří konstrukční rovinu uprostřed mezi dvěma rovinami nebo rovinnými plochami, nebo rovinu půlící jejich úhel, když se protínají pod úhlem. Použijte ji jako rovinu souměrnosti pro zrcadlení.", + "help.construct.plane.offset": "Vytvoří konstrukční rovinu rovnoběžnou s rovinou nebo rovinnou plochou ve zvolené vzdálenosti nebo procházející bodem. Použijte ji k náčrtu v dané výšce nad plochou.", + "help.construct.plane.perpendicular": "Vytvoří konstrukční rovinu, která obsahuje přímku nebo hranu a je kolmá k rovině nebo ploše. Použijte ji k náčrtu kolmo na model podél hrany.", + "help.construct.plane.tangent": "Vytvoří konstrukční rovinu, která se v bodě dotýká válce, kužele, koule nebo anuloidu. Použijte ji k náčrtu na boku kulatého dílu.", + "help.construct.plane.three_points": "Vytvoří konstrukční rovinu procházející třemi body. Použijte ji pro rovinu danou polohami na modelu, například třemi rohy.", + "help.construct.plane.two_edges": "Vytvoří konstrukční rovinu procházející dvěma přímými hranami nebo úsečkami, které leží v jedné rovině. Použijte ji pro rovinu napříč dílem, například vedenou dvěma protilehlými hranami.", + "help.construct.point.along_path": "Vytvoří konstrukční bod v zadané poloze podél hrany nebo křivky. Použijte ho jako referenci někde uprostřed cesty.", + "help.construct.point.center": "Vytvoří konstrukční bod ve středu kruhové hrany, kružnice nebo oblouku náčrtu, koule nebo anuloidu. Použijte ho jako referenci pro otvory, osy a klouby.", + "help.construct.point.edge_plane": "Vytvoří konstrukční bod tam, kde hrana nebo úsečka prochází rovinou nebo rovinnou plochou. Použijte ho k označení místa, kde cesta protíná rovinu.", + "help.construct.point.three_planes": "Vytvoří konstrukční bod tam, kde se protínají tři roviny nebo rovinné plochy. Použijte ho jako referenci rohu i tam, kde model žádný vrchol nemá.", + "help.construct.point.two_edges": "Vytvoří konstrukční bod tam, kde se dvě hrany nebo úsečky protínají nebo si jsou nejblíže. Použijte ho jako referenci ve virtuálním rohu.", + "help.construct.point.vertex": "Vytvoří konstrukční bod ve vrcholu nebo v bodě náčrtu. Použijte ho jako referenci, která zůstane, i když se náčrt nebo těleso kolem něj skryje.", + "help.contact.check": "Vypíše dvojice dílů v sadách kontaktů, které se v aktuální poloze navzájem prostupují. Použijte to k nalezení kolizí dříve, než začnete pohánět klouby nebo spustíte studii pohybu.", + "help.contact.create": "Seskupí dva nebo více dílů do sady kontaktů, jejíž tělesa se při pohybu kloubů nesmějí prostupovat. Použijte to pro díly, které se mají při dotyku zastavit, například páku o její zarážku.", + "help.contact.disable_all": "Vypne kontakt, takže poháněné klouby a studie pohybu se už nezastavují tam, kde se díly dotýkají. Použijte to, aby se díly zase mohly pohybovat volně, nebo aby velká sestava zůstala svižná.", + "help.contact.edit": "Změní sadu kontaktů: díly v ní, její název nebo zda je potlačená; sadu lze také smazat. Použijte to k úpravě toho, které díly se nesmějí prostupovat.", + "help.contact.enable_all": "Vloží všechny díly sestavy do jedné sady kontaktů, takže se žádné dva díly nemohou při pohybu kloubů prostupovat. Použijte to pro rychlou kontrolu celého mechanismu.", + "help.contact.enable_sets": "Zapne kontakt pro vámi definované sady kontaktů, takže poháněné klouby a studie pohybu se zastaví tam, kde by se jejich díly prostoupily. Použijte to k obnovení svých sad po vypnutí kontaktu.", + "help.contact.list": "Vypíše sady kontaktů návrhu, díly v každé z nich a informaci, zda je kontakt zapnutý. Použijte to ke kontrole nastavení kontaktu před poháněním sestavy.", + "help.document.inspect": "Zobrazí přehled návrhu: jeho komponenty, tělesa, náčrty, prvky a parametry. Použijte to, abyste se zorientovali v návrhu, který jste nevytvořili sami.", + "help.document.units": "Změní jednotky, ve kterých návrh zobrazuje délky, například mm nebo palce. Geometrie si zachová velikost; řídí se jimi jen zobrazení a holá čísla.", + "help.edit.checkpoint": "Zapamatuje si návrh, aktivní náčrt a historii vrácení zpět tak, jak jsou nyní. Použijte to před zkoušením něčeho riskantního, od čeho se možná budete chtít vrátit.", + "help.edit.restore_checkpoint": "Vrátí se ke kontrolnímu bodu, včetně historie vrácení zpět. Použijte to k zahození všeho, co se od kontrolního bodu udělalo, jedním krokem.", + "help.explode.create": "Vytvoří rozložený pohled, který odsune díly od středu sestavy nebo o zadané posuny. Použijte to k ukázce, jak se sestava skládá, pro návody nebo prezentace.", "help.face.delete": "Odstraní vybrané plochy a nechá sousední plochy uzavřít mezeru, nebo mezeru ponechá otevřenou jako plochu. Hodí se k odstranění zaoblené hrany, otvoru nebo nálitku, které překážejí.", + "help.file.insert_mesh": "Přidá soubor 3MF, STL, OBJ, PLY nebo glTF do návrhu jako síťová tělesa v nové komponentě. Použijte to k umístění staženého nebo naskenovaného modelu vedle návrhu jako reference.", + "help.file.insert_step": "Přidá soubor STEP, IGES, FreeCAD nebo Rhino do návrhu jako plná tělesa v nové komponentě; díly sestavy se stanou vnořenými komponentami. Použijte to k převzetí modelů od dodavatelů nebo z jiných CAD programů.", + "help.file.save_mesh": "Uloží tělesa jako síť STL, 3MF nebo OBJ s vámi zvolenou hladkostí zakřivených ploch. Použijte to k přípravě dílů pro 3D tisk nebo pro programy, které čtou sítě.", + "help.file.send_to_slicer": "Uloží vybraná tělesa jako soubor 3MF a otevře jej ve zvoleném sliceru. Použijte to k přechodu od modelu k tisku jedním krokem.", "help.font_details": "Rozhraní používá vestavěná písma egui (Ubuntu Light a Hack pod licencemi Ubuntu Font Licence a MIT/Bitstream Vera; Noto Emoji a písmo ikon emoji pod licencemi OFL a MIT). Text náčrtu používá Liberation Sans (SIL OFL 1.1). Volitelný balíček písem craft-fonts od The Crafting Apps (BIZ UDPGothic, Shippori Mincho, BIZ UDMincho, Noto Sans CJK SC, Noto Sans Arabic; SIL OFL 1.1) není součástí sestavení SolidCraft.", "help.fonts": "Písma", + "help.frame.member": "Umístí profil, například trubku nebo hliníkový profil, podél úseček náčrtu rámu, jeden prvek na každou úsečku. Použijte to k vytváření rámů, regálů a svařovaných konstrukcí.", + "help.inspect.accessibility": "Obarví plochy, ke kterým se nástroj dostane z jednoho směru, a ty, ke kterým ne. Použijte to ke kontrole dílu pro obrábění nebo lisování z této strany.", + "help.inspect.center_of_mass": "Zobrazí těžiště těles, každého zvlášť i všech dohromady, při rovnoměrné hustotě. Použijte to ke kontrole vyvážení a místa, kde se díl převrátí.", + "help.inspect.colored_components": "Dá každé komponentě vlastní dočasnou barvu, aniž by změnil vzhledy. Použijte to k rychlému rozlišení dílů sestavy.", + "help.inspect.compare": "Porovná dvě tělesa a ukáže, kde jedno má materiál, který druhé nemá, s objemem a odchylkou. Použijte to ke kontrole změněného nebo importovaného dílu proti originálu.", + "help.inspect.continuity": "Zkontroluje, jak hladce plochy navazují podél hran: mezery, zlomy nebo skoky křivosti, a spoje obarví. Použijte to k nalezení míst, kde se povrchy nespojují čistě.", + "help.inspect.curvature_comb": "Nakreslí hřebeny podél křivek, hran nebo ploch, jejichž zuby ukazují, jak silně se ohýbají. Použijte to k odhalení plochých míst, zlomů a nerovnoměrné křivosti.", + "help.inspect.curvature_map": "Obarví plochy podle toho, jak silně jsou zakřivené, červeně tam, kde je poloměr malý. Použijte to k nalezení těsných míst a k posouzení hladkosti povrchu.", + "help.inspect.draft": "Obarví plochy podle jejich úhlu úkosu vůči směru vyjímání a označí ty s příliš malým úkosem. Použijte to ke kontrole dílu před lisováním nebo odléváním.", + "help.inspect.environment_map": "Zobrazí model s odrazivým, zrcadlovým povrchem. Použijte to k posouzení průběhu ploch podle toho, jak po nich probíhají odrazy.", + "help.inspect.interference": "Najde tělesa, která se překrývají, a ukáže každé překrytí s jeho objemem. Použijte to ke kontrole sestavy, zda v ní nejsou kolidující díly.", + "help.inspect.isocurve": "Nakreslí izoparametrické křivky ploch, tedy mřížku, na které je povrch postaven. Použijte to k zobrazení toho, jak povrch plyne a kde se zhušťuje.", + "help.inspect.measure": "Změří délky, vzdálenosti, úhly, obsahy a poloměry mezi vybranými objekty, nebo objem a hmotnost těles. Použijte to ke kontrole rozměrů a vůlí.", + "help.inspect.minimum_radius": "Najde nejmenší poloměr křivosti křivek, hran nebo ploch a místo, kde se nachází. Použijte to ke kontrole, zda tvar dokáže sledovat nástroj nebo tryska.", + "help.inspect.section": "Rozřízne pohled podél roviny a vyšrafuje řezné plochy, aniž by změnil model. Použijte to k nahlédnutí dovnitř dílů a sestav.", + "help.inspect.zebra": "Zobrazí na plochách modelu odražené černobílé pruhy. Použijte to k posouzení hladkosti: pruhy, které se na hraně přeruší nebo zlomí, ukazují špatný přechod.", + "help.joint.as_built": "Spojí dva díly tam, kde už jsou, aniž by některý přesunul, a nastaví, jak se mohou dál pohybovat. Použijte to pro díly, které jste už umístili do konečné polohy.", + "help.joint.cam": "Spojí otáčející se vačku se zdvihátkem, které se posouvá nebo kývá podle toho, jak pod ním prochází obrys vačky. Použijte to pro vačky, excentry a rozvody ventilů.", + "help.joint.create": "Spojí dva díly ve zvolených počátcích kloubu a přesune druhý díl na místo; typ kloubu určuje, jak se mohou dál pohybovat. Použijte to k sestavení sestavy, jejíž díly se pohybují jako skutečný mechanismus.", + "help.joint.drive": "Nastaví úhel nebo vzdálenost kloubu a pohne s ním díly v rámci jeho limitů; propojené klouby a uzavřené smyčky ho následují. Použijte to ke kontrole, zda se mechanismus pohybuje, jak má.", + "help.joint.gear": "Spojí dva otáčející se klouby převodovým poměrem, takže pohon jednoho otáčí druhým. Použijte to pro čelní a vnitřní ozubená kola; poměr se vezme z poloměrů roztečných kružnic nebo z počtu zubů.", + "help.joint.limits": "Nastaví, jak daleko se kloub smí otočit nebo posunout, jako minimum a maximum, nebo tyto limity zruší. Použijte to, aby závěs, posuvný člen nebo páka zůstaly ve svém skutečném rozsahu pohybu.", + "help.joint.motion_link": "Spojí dva klouby poměrem a posunutím, takže pohon jednoho pohne druhým. Použijte to pro pevný vztah mezi klouby, který nepokrývají Ozubený převod, Hřeben a pastorek ani Šroubový pohyb.", + "help.joint.origin": "Umístí na díl pojmenovaný počátek kloubu: do středu plochy nebo kulatého konce, do poloviny hrany, do rohu nebo do bodu. Použijte to k přípravě míst, kde se klouby upevní; počátek zvolený na ploše ji sleduje, když se díl změní co do velikosti.", + "help.joint.rack_pinion": "Spojí otáčející se pastorek s posuvným hřebenem tak, že se hřeben posune o délku roztečné kružnice, po které se pastorek odvalí. Použijte to pro hřebenové pohony, řízení a lineární stolky.", + "help.joint.reach": "Přesune bod na dílu k cíli změnou kloubů na jeho řetězci v rámci jejich limitů. Použijte to k nastavení polohy mechanismu podle toho, kde má být jeho konec, například hrot ramene robota.", + "help.joint.rigid_group": "Pevně spojí vybrané díly tak, jak jsou, takže se pohybují jako jeden. Použijte to pro díly, které k sobě patří a nemají mezi sebou žádný pohyb.", + "help.joint.screw": "Spojí otáčení a posuv stoupáním, takže každé otočení posune díl o tuto vzdálenost. Použijte to pro pohybové šrouby, šrouby a závitová vřetena.", + "help.library.apply": "Použije materiál nebo vzhled ze souboru místní knihovny na tělesa, plochy nebo komponenty. Použijte to k opětovnému použití materiálů uložených z jiného návrhu.", + "help.library.export": "Uloží materiály a vzhledy návrhu do souboru knihovny. Použijte to ke sdílení nebo k jejich opětovnému použití v jiných návrzích.", + "help.library.inspect": "Zkontroluje soubor knihovny a vypíše jeho materiály a vzhledy, aniž by změnil návrh. Použijte to, abyste před použitím viděli, co knihovna obsahuje.", "help.licences": "Licence", "help.material.assign": "Určí, z čeho jsou vybraná tělesa vyrobena, například z oceli, hliníku nebo ABS. Hodí se k tomu, aby hmotnost a tíha odpovídaly skutečnému dílu.", + "help.material.properties.validate": "Zkontroluje kartu dat materiálu na shodu čísel a chybějící hodnoty. Použijte to dříve, než se na data spolehnete při výpočtu hmotnosti nebo tuhosti.", + "help.mesh.convert": "Přestaví síťové těleso, například STL, na upravitelné plné těleso s rovinnými a válcovými plochami. Použijte to k úpravě importovaných strojních dílů jako modelovaného tělesa.", "help.mit": "Licence MIT", + "help.motion.study": "Nastaví hodnoty kloubů v krocích na časové ose a přehraje je jako animaci. Použijte to ke sledování pohybu mechanismu v posloupnosti a ke kontrole vůlí po cestě.", + "help.motion.trace": "Sleduje dráhu, kterou opisuje bod na dílu při přehrávání studie pohybu nebo při poháněném kloubu, a po potvrzení ji uloží jako křivku 3D náčrtu. Použijte to k zobrazení dosahu mechanismu nebo k vytváření dalších prvků na jeho dráze.", "help.notice": "NOTICE", + "help.occurrence.ground": "Ukotví díl na místě, nebo ho znovu uvolní, aby klouby pohybovaly ostatními díly kolem něj. Použijte to pro rám nebo základnu, na které je zbytek sestavy postaven.", "help.occurrence.move": "Posouvá díl sestavy jen v těch stupních volnosti, které povolují jeho klouby. Vyberte plochu dílu a pak táhněte za dostupnou šipku nebo oblouk.", "help.parameters.change": "Otevře tabulku pojmenovaných hodnot a výrazů, které řídí rozměry návrhu. Hodí se ke změně rozměru na jednom místě, kterou převezmou všechny prvky, jež jej používají.", + "help.parameters.export": "Uloží parametry návrhu jako CSV nebo JSON. Použijte to k jejich úpravě v tabulkovém procesoru nebo k přenosu do jiného návrhu.", + "help.parameters.graph": "Ukáže, které parametry a prvky používají které jiné, a případné smyčky nebo chyby mezi nimi. Použijte to, abyste viděli, co změna parametru ovlivní.", + "help.parameters.import": "Načte parametry z CSV nebo JSON do návrhu, buď všechny, nebo žádný z nich. Použijte to k převzetí hodnot z tabulky nebo jiného návrhu.", + "help.parameters.move": "Přesune uživatelský parametr před jiný nebo za jiný v seznamu. Použijte to, aby související parametry zůstaly pohromadě.", + "help.parts.fastener": "Přidá normalizovaný šroub, svorník, matici, podložku, kolík nebo ložisko ve zvolené velikosti. Po umístění na otvor se do něj pomocí kloubu usadí, takže spojovací prvky nemusíte modelovat sami.", + "help.parts.insert": "Vloží normalizovaný díl ve zvolené řadě, velikosti a délce. Po umístění na okraj nebo stěnu otvoru se na otvor usadí pevným kloubem.", + "help.parts.library": "Otevře normalizované díly: šrouby, svorníky, matice, podložky, stavěcí šrouby, kolíky a ložiska v normalizovaných velikostech. Použijte to k přidání normalizovaného dílu místo jeho vlastního modelování.", + "help.parts.resize": "Změní velikost nebo délku vloženého normalizovaného dílu a přestaví jej; jeho klouby ho drží usazený. Použijte to, když se ukáže, že je šroub příliš dlouhý nebo že otvor potřebuje jinou velikost.", + "help.plastic.assign_rule": "Přiřadí tělesům pravidlo pro plasty, takže jejich plastové prvky přebírají jeho úkos a vůli jako výchozí hodnoty. Použijte to k nastavení lisovaného dílu jednou pro všechny jeho prvky.", + "help.plastic.boss": "Přidá na plochu nálitek pro šroub: sloupek s volitelným otvorem, úkosem, zaoblením u paty a podpůrnými žebry. Použijte to pro šroubové body v lisovaných nebo tištěných krytech.", + "help.plastic.lip": "Přidá osazení nebo odpovídající drážku podél okraje tělesa vytvořeného skořepinou. Použijte to, aby se dvě poloviny krytu navzájem zaměřily a těsně se zavřely.", + "help.plastic.manage_rules": "Vytvoří a upraví pravidla pro plasty: materiál, tloušťku stěny, poloměry, vůli a úkos. Použijte je k tomu, aby prvky lisovaného dílu byly jednotné.", + "help.plastic.rest": "Přidá na plochu opěrku: kulatou nebo obdélníkovou podložku, plnou nebo dutou, s úkosem. Použijte ji jako podporu nebo distanční sloupek v lisovaném krytu.", + "help.plastic.snap_fit": "Přidá na plochu zacvakávací hák se zadanou délkou, tloušťkou a západkou. Použijte to k sepnutí dílů bez šroubů.", "help.pointcloud.insert": "Vložte sken PLY, XYZ nebo PTS jako mračno bodů vedle modelu. Použijte ho jako referenci při tvorbě modelu z naměřených dat.", + "help.sheet.convert": "Změní desku nebo těleso se skořepinou stejné tloušťky na plechové těleso s ohyby na zvolených hranách. Použijte to, aby šel existující díl rozvinout.", + "help.sheet.export_bend_table": "Uloží tabulku ohybů plechového tělesa jako CSV nebo JSON: úhly, poloměry a polohy ohybů v rozvinu. Použijte to pro ohraňovací lis nebo dílnu.", + "help.sheet.export_dxf": "Uloží rozvin jako DXF s obrysy, čarami ohybů a volitelnými poznámkami k ohybům. Použijte to pro řezání laserem, plazmou nebo vodním paprskem.", + "help.sheet.flange": "Přidá přírubu: základní desku z náčrtu, stěnu podél otevřené kontury náčrtu nebo ohnutou stěnu na hranách plechu. Použijte to k vytváření plechových dílů.", + "help.sheet.flat_pattern": "Vytvoří rozvin plechového tělesa s jeho velikostí, výřezy a čárami ohybů. Použijte to ke kontrole polotovaru před řezáním.", + "help.sheet.fold": "Ohne plech podél úsečky náčrtu o zadaný úhel a poloměr. Použijte to k ohnutí plochého plechu poté, co do něj vyřežete prvky.", + "help.sheet.hem": "Přehne okraj plechu zpět na sebe. Použijte to pro bezpečné, tuhé okraje bez ostrých otřepů.", + "help.sheet.manage_rules": "Vytvoří a upraví pravidla pro plech: tloušťku, poloměr ohybu, K-faktor, odlehčení a mezery. Použijte je, aby odpovídala materiálu a nástrojům vaší dílny.", + "help.sheet.path_flange": "Přidá řetěz přírub podél hrany plechu podle otevřeného řetězce úseček náčrtu. Použijte to pro profily, například tvar U nebo Z, jedním krokem.", + "help.sheet.perforate": "Přidá pole otvorů přes celý plochý plech ve střídavém nebo čtvercovém rozložení. Použijte to pro větrací otvory, mřížky a odlehčené panely.", + "help.sheet.refold": "Znovu ohne rozvinuté plechové těleso zpět. Použijte to po přidání prvků do plochého plechu.", + "help.sheet.unfold": "Rozvine všechny ohyby plechového tělesa. Použijte to k přidání otvorů nebo výřezů, které musí překračovat ohyb, a pak plech znovu ohněte.", + "help.sketch.arc.center_point": "Nakreslí kruhový oblouk kolem středu, od počátečního bodu ke koncovému. Použijte to, když je střed oblouku známý, například poloměr kolem otvoru nebo čepu.", + "help.sketch.arc.tangent": "Nakreslí oblouk, který začíná na konci úsečky nebo oblouku a pokračuje tečně k němu. Použijte to k pokračování řetězce hladkým ohybem místo rohu.", + "help.sketch.arc.three_point": "Nakreslí oblouk od počátečního bodu ke koncovému přes třetí bod. Použijte to, když oblouk musí procházet zadanými body, a ne kolem známého středu.", + "help.sketch.arc3d": "Nakreslí přesný kruhový oblouk v prostoru třemi body, se zapnutým 3D náčrtem. Použijte to pro ohyby cest a vodicích drah, které opouštějí rovinu náčrtu.", + "help.sketch.auto_constrain": "Přidá vazby, které náčrt už skoro má, například vodorovnost, rovnoběžnost nebo totožnost, a pak jej okótuje, dokud není plně určen. Použijte to k zamknutí zhruba nakresleného náčrtu jedním krokem.", + "help.sketch.auto_constrain_from_datum": "Funguje jako Automatické vazby, ale polohy měří od zvoleného referenčního bodu místo počátku. Použijte to, když je díl rozvržen od jednoho referenčního bodu, například rohu nebo otvoru.", + "help.sketch.blend_curve": "Spojí konce dvou křivek hladkým splajnem, který je tečný k oběma, nebo na přání i spojitý v křivosti. Použijte to k překlenutí mezery mezi křivkami bez zlomu.", + "help.sketch.break": "Rozdělí křivku na části tam, kde ji kříží jiné křivky, nebo v bodech, které na ni umístíte. Použijte to, když část křivky potřebuje vlastní vazbu nebo se má smazat samostatně.", + "help.sketch.cancel": "Opustí náčrt a zahodí vše, co se změnilo od jeho vytvoření nebo otevření k úpravám. Zpět změny vrátí.", + "help.sketch.centerline": "Přepíná úsečky mezi běžnými úsečkami a osami, které profily vynechávají. Použijte osu jako osu rotace, zrcadlení nebo vazby symetrie.", + "help.sketch.chamfer.distance_angle": "Zkosí roh mezi dvěma úsečkami podle vzdálenosti podél první úsečky a úhlu. Použijte to, když výkres udává sražení jako délku a úhel, například 2 mm × 30°.", + "help.sketch.chamfer.equal_distance": "Zkosí roh mezi dvěma úsečkami ve stejné vzdálenosti zpět podél obou. Použijte to pro obvyklé sražení 45° na obrysu.", + "help.sketch.chamfer.two_distance": "Zkosí roh mezi dvěma úsečkami podle samostatné vzdálenosti podél každé úsečky. Použijte to pro sražení jiná než 45°, zadaná dvěma délkami.", + "help.sketch.circle.center": "Nakreslí kružnici ze středu a průměru. Použijte to pro otvory, nálitky a jiné kulaté prvky se známým středem.", + "help.sketch.circle.three_point": "Nakreslí kružnici procházející třemi body. Použijte to, když kružnice musí procházet zadanými body, například existující geometrie.", + "help.sketch.circle.three_tangent": "Nakreslí kružnici, která se dotýká tří úseček, kružnic nebo oblouků. Použijte to k vložení kružnice do prostoru mezi tři křivky, například jako vepsanou kružnici trojúhelníku.", + "help.sketch.circle.two_point": "Nakreslí kružnici ze dvou konců jejího průměru. Použijte to, když jsou známé body na okraji kružnice, a ne její střed.", + "help.sketch.circle.two_tangent": "Nakreslí kružnici zadané velikosti, která se dotýká dvou úseček, kružnic nebo oblouků. Použijte to pro zaoblení pevným poloměrem mezi dvěma křivkami.", + "help.sketch.conic": "Nakreslí kuželosečku mezi dvěma koncovými body, tvarovanou vrcholovým bodem a hodnotou rho. Použijte to pro hladké přechody, jejichž plnost chcete doladit, od ploché až po téměř ostrý roh.", + "help.sketch.constrainer": "Použije vazbu, kterou jedna nebo dvě vybrané křivky už skoro mají, například vodorovnost, rovnoběžnost, tečnost nebo rovnost. Použijte to k uspořádání ručně nakresleného náčrtu po jedné vazbě.", + "help.sketch.constraint.coincident": "Umístí bod na jiný bod, křivku nebo osu náčrtu. Použijte to k zavření mezer v profilu nebo k umístění bodu na úsečku.", + "help.sketch.constraint.collinear": "Umístí dvě úsečky na jednu přímku. Použijte to k zarovnání přerušených hran, například obou stran zářezu.", + "help.sketch.constraint.concentric": "Dá dvěma kružnicím nebo obloukům stejný střed. Použijte to pro kroužky, záhlubky a hřídele s osazením.", + "help.sketch.constraint.curvature": "Zajistí, aby dvě křivky, které se setkávají v koncovém bodě, pokračovaly se stejnou křivostí (G2). Použijte to tam, kde tečné napojení není dost hladké, například na viditelných plochách.", + "help.sketch.constraint.equal": "Vyrovná délky úseček, nebo poloměry kružnic a oblouků. Použijte to, aby jeden rozměr řídil několik shodných prvků.", + "help.sketch.constraint.fix": "Zafixuje body nebo křivky tam, kde jsou, nebo je znovu uvolní. Použijte to pro geometrii, která se nesmí pohnout při tažení nebo řešení náčrtu.", + "help.sketch.constraint.horizontal_vertical": "Učiní úsečku, nebo spojnici dvou bodů, vodorovnou nebo svislou, podle toho, co je blíže. Použijte to k zarovnání obrysu s osami náčrtu.", + "help.sketch.constraint.midpoint": "Umístí bod doprostřed úsečky, nebo spojí středy dvou úseček. Použijte to k vystředění geometrie na hraně.", + "help.sketch.constraint.parallel": "Učiní dvě úsečky rovnoběžnými. Použijte to pro strany, které mají zůstat rovnoběžné při změně jiných rozměrů.", + "help.sketch.constraint.perpendicular": "Zajistí, aby se dvě úsečky protínaly v pravém úhlu. Použijte to pro pravoúhlé rohy mezi úsečkami, které neleží na osách náčrtu.", + "help.sketch.constraint.polygon": "Změní uzavřený řetězec úseček na pravidelný mnohoúhelník: stejné strany, všechny rohy na jedné kružnici. Použijte to, aby se ručně nakreslený mnohoúhelník stal pravidelným a zůstal takový.", + "help.sketch.constraint.symmetry": "Zajistí, aby dva body nebo křivky byly symetrické podle přímky nebo podle bodu. Použijte to, aby profil zůstal symetrický při změně jedné strany.", + "help.sketch.constraint.tangent": "Zajistí, aby se dvě křivky dotýkaly tečně, například úsečka a oblouk. Použijte to pro hladká napojení bez zlomu.", + "help.sketch.construction": "Přepíná křivky mezi běžnou geometrií a konstrukční geometrií, kterou profily vynechávají. Použijte konstrukční křivky jako pomocné prvky pro rozvržení, reference a kóty.", "help.sketch.construction_line": "Vytvoří nekonečnou konstrukční přímku procházející dvěma body. Použijte ji k rozvržení náčrtu bez rozdělování profilů.", "help.sketch.construction_line.angle": "Vytvoří nekonečnou konstrukční přímku pod úhlem od osy X náčrtu. Vyberte bod a zadejte úhel, nebo vyberte jeho směr.", "help.sketch.construction_line.bisector": "Vyberte dvě protínající se přímé úsečky náčrtu a pak jednu ze dvou os úhlu. Výsledek sleduje své zdrojové úsečky.", @@ -1412,11 +1564,71 @@ "help.sketch.construction_line.tangent_external": "Vyberte kružnici nebo kruhový oblouk, pak vnější bod a řešení tečny. Náhledy ukazují jen body dotyku na nakresleném oblouku.", "help.sketch.construction_line.tangent_two": "Vyberte dvě kružnice nebo kruhové oblouky a pak v náhledu zvolte společnou tečnu. Body dotyku musí ležet na nakreslených křivkách.", "help.sketch.construction_line.vertical": "Vytvoří nekonečnou svislou konstrukční přímku procházející bodem. Její svislá vazba platí i při pozdějších úpravách.", + "help.sketch.copy": "Zkopíruje vybrané křivky a body náčrtu spolu s vazbami a kótami mezi nimi. Vložte je do tohoto náčrtu nebo do jiného.", + "help.sketch.create": "Zahájí nový 2D náčrt na rovině nebo na rovinné ploše modelu. Každé vysunutí, rotace a tažení začíná náčrtem.", + "help.sketch.cut": "Zkopíruje vybranou geometrii náčrtu a smaže ji z náčrtu, vše jedním krokem vrácení zpět. Vložte ji jinam v tomto náčrtu nebo do jiného.", + "help.sketch.dimension": "Přidá řídicí kótu: délku, vzdálenost, úhel, poloměr nebo průměr podle toho, co vyberete. Zadejte hodnotu nebo název parametru a náčrt ji následuje.", + "help.sketch.edit": "Otevře existující náčrt k úpravám. Prvky na něm postavené se aktualizují, když náčrt dokončíte.", + "help.sketch.ellipse": "Nakreslí elipsu ze středu, konce hlavní osy a bodu na vedlejší straně. Použijte to pro oválné otvory, vačky a jiné protáhlé oblé tvary.", "help.sketch.equation_curve": "Nakreslí křivku podle vzorců: x(t) a y(t), nebo y = f(x), v zadaném rozsahu. Vzorce mohou používat parametry a křivka se podle nich mění: použijte ji pro sinusovku, spirálu nebo evolventu zubu ozubeného kola.", + "help.sketch.extend": "Prodlouží úsečku nebo oblouk až k nejbližší křivce v cestě, nebo prodlouží dvě křivky, dokud se nesetkají. Použijte to k zavření mezer v profilu po oříznutí nebo úpravách.", + "help.sketch.fillet": "Zaoblí roh, kde se setkávají dvě úsečky nebo oblouky, obloukem zadaného poloměru. Více rohů zaoblených společně sdílí jeden poloměr.", + "help.sketch.finish": "Opustí náčrt a vrátí se k modelu. Jeho uzavřené profily jsou pak připraveny pro Vysunutí, Rotaci a další prvky.", + "help.sketch.finish_auto_constrain": "Spustí Automatické vazby na náčrtu a pak jej dokončí. Použijte to k opuštění plně určeného náčrtu jedním krokem.", + "help.sketch.fit_curves_to_section": "Protne síťové nebo plné těleso rovinou náčrtu a proloží řez úsečkami a oblouky. Použijte to k přestavbě naskenovaného nebo importovaného dílu jako upravitelného náčrtu.", + "help.sketch.include_3d": "Přenese hrany modelu, vrcholy a křivky jiných náčrtů do tohoto náčrtu jako propojené reference, přičemž ty mimo jeho rovinu do ní zploští. Použijte to k tvorbě na geometrii, která neleží v rovině náčrtu.", + "help.sketch.infinite": "Přepíná úsečky mezi běžnými úsečkami a nekonečnými konstrukčními přímkami, které vedou přes celý pohled. Použijte je jako referenční čáry rozvržení, které nikdy nekončí příliš brzy.", + "help.sketch.insert_dxf": "Načte křivky výkresu DXF do náčrtu, ve zvoleném měřítku, natočení a poloze. Použijte to k navázání na 2D výkres z jiného programu, například obrys pro řezání laserem.", + "help.sketch.insert_svg": "Načte cesty výkresu SVG do náčrtu, ve zvoleném měřítku, natočení a poloze. Použijte to k přeměně loga nebo vektorového obrysu na křivky, které lze vysunout.", + "help.sketch.inspect": "Otevře zprávu o otevřeném náčrtu: kolik stupňů volnosti zbývá a zda je plně zavazbený. Použijte to ke zjištění, co se ještě hýbe, než náčrt dokončíte.", + "help.sketch.intersect": "Přidá křivky nebo body, kde tělesa, plochy, hrany nebo jiné náčrty protínají rovinu náčrtu. Zůstanou propojené a sledují model, když se změní.", + "help.sketch.intersection_curve": "Vytvoří 3D křivky tam, kde se setkávají dvě tělesa nebo plochy, propojené s nimi. Použijte je jako cesty nebo vodicí dráhy, které sledují model.", + "help.sketch.isoparametric_curve": "Vytvoří 3D křivku ve směru U nebo V plochy bodem, který vyberete, propojenou s plochou. Použijte ji jako cestu nebo dělicí čáru, která sleduje povrch.", + "help.sketch.line": "Kreslí spojené úsečky bod po bodu, jako otevřený řetězec nebo uzavřený obrys. Je to základní nástroj pro většinu profilů.", + "help.sketch.line.midpoint": "Nakreslí úsečku od jejího středu k jednomu konci, přičemž druhý konec se zrcadlí. Použijte to pro úsečky vystředěné na bod, například souměrnou hranu.", + "help.sketch.line3d": "Nakreslí spojené přímé úsečky mezi body v prostoru, se zapnutým 3D náčrtem. Použijte to pro cesty a rámy, které opouštějí rovinu náčrtu.", + "help.sketch.mirror": "Zrcadlí křivky a body náčrtu podle přímky nebo osy a přidá vazby symetrie. Použijte to k nakreslení poloviny symetrického profilu a jeho doplnění.", + "help.sketch.move": "Přesune nebo otočí křivky a body náčrtu, nebo jejich kopii. Použijte to k přemístění geometrie při zachování jejího tvaru a vazeb.", + "help.sketch.offset": "Vytvoří kopii řetězce křivek v zadané vzdálenosti, na jedné straně nebo na obou. Použijte to pro stěny, obrysy a vůle; vzdálenost zůstává upravitelnou kótou.", + "help.sketch.paste": "Vloží do aktivního náčrtu geometrii náčrtu, kterou jste zkopírovali nebo vyjmuli. Umístěte ji do bodu nebo ji posuňte o odsazení.", + "help.sketch.pattern.circular": "Zkopíruje geometrii náčrtu kolem středu, po celém kruhu nebo o zadaný úhel. Použijte to pro roztečné kružnice šroubů, paprsky a zuby.", + "help.sketch.pattern.rectangular": "Zkopíruje geometrii náčrtu v řádcích a sloupcích v zadaných roztečích. Použijte to pro mřížky otvorů, drážek nebo větracích otvorů.", + "help.sketch.point": "Umístí bod náčrtu. Použijte ho jako referenci pro kóty, vazby, středy otvorů nebo pozdější prvky.", + "help.sketch.point3d": "Umístí bod v prostoru, se zapnutým 3D náčrtem. Použijte ho jako referenci pro 3D křivky a cesty.", + "help.sketch.polygon.circumscribed": "Nakreslí pravidelný mnohoúhelník kolem kružnice, velikost se určuje od středu ke středu strany. Použijte to, když je známé rozevření klíče, například u šestihranné matice.", + "help.sketch.polygon.edge": "Nakreslí pravidelný mnohoúhelník ze dvou konců jedné strany. Použijte to, když je známá délka a poloha strany.", + "help.sketch.polygon.inscribed": "Nakreslí pravidelný mnohoúhelník do kružnice, velikost se určuje od středu k rohu. Použijte to, když je známá velikost přes rohy.", + "help.sketch.project": "Přenese hrany modelu, plochy, vrcholy a křivky jiných náčrtů do tohoto náčrtu, promítnuté do jeho roviny. Zůstanou propojené a sledují model, když se změní.", + "help.sketch.project_to_surface": "Promítne křivky jiných náčrtů ve směru normály tohoto náčrtu na plochu jako propojené 3D křivky. Použijte to k umístění loga, dělicí čáry nebo cesty na zakřivený povrch.", + "help.sketch.rectangle.center": "Nakreslí obdélník ze středu a jednoho rohu. Použijte to pro obrysy vystředěné na bod, například počátek.", + "help.sketch.rectangle.rounded": "Nakreslí obdélník se všemi čtyřmi rohy zaoblenými jedním poloměrem. Použijte to pro desky, víka a otvory se zaoblenými rohy.", + "help.sketch.rectangle.three_point": "Nakreslí obdélník z jedné hrany a bodu na protější straně. Použijte to pro obdélníky pootočené vůči osám náčrtu.", + "help.sketch.rectangle.two_point": "Nakreslí obdélník ze dvou protilehlých rohů. Použijte to pro obrysy desek, bloků a kapes.", + "help.sketch.scale": "Změní měřítko křivek a bodů náčrtu zadaným činitelem kolem základního bodu. Použijte to ke změně velikosti části náčrtu bez nového kreslení.", + "help.sketch.slot.arc_center": "Nakreslí zakřivenou drážku zadané šířky podél oblouku daného středem, začátkem a koncem. Použijte to pro stavěcí drážky kolem čepu.", + "help.sketch.slot.arc_three_point": "Nakreslí zakřivenou drážku zadané šířky podél oblouku procházejícího třemi body. Použijte to, když dráha drážky musí procházet zadanými body.", + "help.sketch.slot.center_point": "Nakreslí přímou drážku zadané šířky od jejího středu ke středu oblouku jednoho konce. Použijte to pro drážky vystředěné na bod.", + "help.sketch.slot.center_to_center": "Nakreslí přímou drážku zadané šířky mezi středy jejích dvou koncových oblouků. Použijte to pro drážky kótované střed–střed, například stavěcí drážky pro šrouby.", + "help.sketch.slot.overall": "Nakreslí přímou drážku zadané šířky od jednoho vnějšího konce k druhému. Použijte to, když záleží na celkové délce drážky.", + "help.sketch.spline.control_point": "Nakreslí kubický splajn tvarovaný řídicími body, kterými neprochází. Použijte to pro hladké, plynulé křivky, které tvarujete přesouváním bodů.", + "help.sketch.spline.control_point_5": "Nakreslí splajn stupně 5 tvarovaný řídicími body. Použijte to tam, kde se křivky musí napojit s hladší křivostí, například na viditelných tvarových plochách.", + "help.sketch.spline.fit_point": "Nakreslí hladký splajn, který prochází každým bodem, který umístíte, otevřený nebo uzavřený. Použijte to k obkreslení tvaru přes známé body.", + "help.sketch.spline3d": "Nakreslí hladkou křivku v prostoru body, se zapnutým 3D náčrtem. Použijte to pro cesty tažení a vedení kabelů, které opouštějí rovinu náčrtu.", + "help.sketch.spun_profile": "Vytvoří v rovině náčrtu obrys, který těleso opíše při otáčení kolem osy. Použijte to k získání siluety soustruženého dílu pro rotaci nebo výkres.", + "help.sketch.text": "Napíše do náčrtu text zvoleným písmem, výškou a úhlem; jeho slova mohou sledovat parametry. Vysuňte jej nebo z něj vytvořte reliéf pro štítky a nápisy.", + "help.sketch.transform": "Změní měřítko, otočí a přesune geometrii náčrtu kolem středu jedním krokem. Použijte to k přemístění a změně velikosti výběru najednou.", + "help.sketch.trim": "Odstraní část křivky mezi nejbližšími průsečíky kolem místa, kam kliknete. Použijte to k vyčištění překrývajících se úseček do uzavřeného profilu.", "help.solid.align": "Posune vybraná tělesa tak, aby se bod, plocha nebo rovina na nich setkala s bodem, plochou nebo rovinou jinde. Hodí se k přiložení dílu k jinému bez měření odsazení.", + "help.solid.boundary_fill": "Vytvoří tělesa z oblastí uzavřených překrývajícími se nástrojovými tělesy, přičemž vyplní jen oblasti, které vyberete. Použijte to k vytvoření tvaru z prostoru, který společně ohraničuje několik těles.", + "help.solid.bounding_solid": "Vytvoří kvádr, který obklopuje vybraná tělesa, s volitelným okrajem. Použijte to k zobrazení celkové velikosti dílu nebo jako polotovar, ze kterého se obrábí.", + "help.solid.box": "Vytvoří kvádr z délky, šířky a výšky, z rohového bodu nebo vystředěný na bod. Použijte to pro rychlý blok, zástupný díl nebo těleso, do kterého se řežou prvky.", "help.solid.chamfer": "Zkosí vybrané hrany plochým sražením o zadanou vzdálenost. Hodí se k otupení ostrých hran nebo ke snadnějšímu zasunutí kolíku či šroubu.", + "help.solid.coil": "Vytvoří šroubovicové těleso, například pružinu, z průměru, počtu závitů, výšky nebo stoupání a kulatého či čtvercového průřezu. Použijte to pro pružiny, cívky a šroubovicové výstupky.", "help.solid.combine": "Sjednotí tělesa, vyjme je z cílového tělesa nebo ponechá jen společný objem; nástrojová tělesa lze ponechat. Hodí se ke slučování dílů, k vyříznutí jednoho tvaru druhým nebo k nalezení místa, kde se dvě tělesa překrývají.", + "help.solid.cone": "Vytvoří kužel z kruhové základny a výšky, nebo komolý kužel, pokud má poloměr i horní strana. Použijte to pro zúžení, nálevky, kuželové záhlubky a hroty.", + "help.solid.cylinder": "Vytvoří válec z poloměru nebo průměru a výšky, stojící na bodě podél osy. Použijte to pro hřídele, kolíky, nálitky a kulaté polotovary.", "help.solid.draft": "Nakloní vybrané plochy o zadaný úhel měřený od neutrální roviny. Hodí se u lisovaných či odlévaných dílů, aby šly snadno vyjmout z formy.", + "help.solid.emboss": "Vytáhne profily náčrtu, například text nebo logo, z plochy ven, nebo je do ní zahloubí. Použijte to pro nápisy, štítky a značení na rovných nebo kulatých plochách.", "help.solid.extrude": "Vysune profil náčrtu do 3D o zadanou vzdálenost a přidá nebo odebere materiál. Hodí se k přeměně plochého obrysu na kvádr, nálitek nebo vybrání.", "help.solid.fillet": "Zaoblí vybrané hrany hladkým kruhovým přechodem o zadaném poloměru. Hodí se ke zjemnění ostrých hran kvůli pevnosti, pohodlí nebo vzhledu.", "help.solid.hole": "Vyvrtá kruhový otvor zadaného průměru ve zvoleném bodě plochy. Hodí se pro průchozí otvory pro šrouby, kolíky a další jednoduché kruhové otvory.", @@ -1425,20 +1637,60 @@ "help.solid.move": "Posune nebo otočí vybraná tělesa, případně jejich kopii, o zadanou vzdálenost a úhel. Hodí se ke změně polohy tělesa nebo k vytvoření dalšího tělesa na jiném místě.", "help.solid.offset_face": "Posune vybrané plochy dovnitř nebo ven o zadanou vzdálenost a okolní plochy je následují. Hodí se k zesílení stěny, prohloubení vybrání nebo zvýšení nálitku bez úprav náčrtu.", "help.solid.pattern.circular": "Opakuje vybrané objekty kolem osy v zadaném počtu a celkovém úhlu. Hodí se pro roztečné kružnice šroubů, paprsky, žebra a zuby.", + "help.solid.pattern.path": "Zkopíruje prvky nebo tělesa podél cesty náčrtu podle počtu a rozteče. Použijte to pro otvory podél zakřivené hrany nebo díly podél kolejnice.", "help.solid.pattern.rectangular": "Zopakuje vybrané objekty v jednom nebo dvou směrech podle zadaného počtu a rozteče. Použijte to pro řady a mřížky otvorů, žeber, kolíků nebo patek.", + "help.solid.pipe": "Vytvoří kulatou trubku podél cesty z křivek náčrtu, plnou nebo dutou se zadanou tloušťkou stěny. Použijte to pro trubky, dráty, kabely a kolejnice.", "help.solid.press_pull": "Přidá nebo odebere materiál při tažení plochy, případně zaoblí hrany při jejich tažení. Použijte to pro rychlé úpravy přímo na modelu.", "help.solid.remove": "Odebere vybraná tělesa z návrhu od tohoto místa na časové ose; dřívější kroky je zachovají. Použijte to k odstranění pomocného tělesa, například řezného nástroje, který už nepotřebujete.", "help.solid.replace_face": "Přesune vybrané rovinné plochy do roviny nebo na rovnoběžnou plochu a těleso se natáhne či zmenší, aby na ni dosáhlo. Použijte to, aby plocha lícovala s jiným dílem nebo konstrukční rovinou.", "help.solid.revolve": "Otočí profil náčrtu kolem osy a vytvoří rotační těleso. Použijte to pro hřídele, knoflíky, misky a každý díl, který je souměrný kolem osy.", + "help.solid.rib": "Přidá tenkou stěnu podél otevřené křivky náčrtu, vyplněnou až k nejbližším plochám tělesa. Použijte to k vyztužení stěn a rohů, například u lisovaných nebo tištěných dílů.", "help.solid.scale": "Zvětší nebo zmenší vybraná tělesa o zadaný činitel, stejný ve všech směrech nebo zvlášť pro každou osu. Použijte to ke změně velikosti importovaného tvaru nebo k započtení smrštění.", "help.solid.shell": "Vytvoří z tělesa skořepinu se stěnami zadané tloušťky a může otevřít vybrané plochy. Použijte to pro krabice, kryty a jiné tenkostěnné díly.", + "help.solid.sphere": "Vytvoří kouli z poloměru nebo průměru kolem středu. Použijte to pro kuličky, knoflíky a zaoblené konce.", "help.solid.split_body": "Rozdělí těleso na samostatná tělesa rovinou, plochou jiného tělesa nebo úsečkou náčrtu. Použijte to pro díly, které se tisknou, obrábějí nebo barví zvlášť.", "help.solid.split_face": "Rozdělí vybrané plochy podél roviny, plochy jiného tělesa nebo křivek náčrtu, aniž by těleso rozřízlo. Použijte to, aby část plochy měla vlastní úkos, barvu nebo odsazení.", "help.solid.sweep": "Protáhne profil náčrtu po cestě a vytvoří těleso. Použijte to pro trubky, lišty, rukojeti a každý díl s konstantním průřezem, který sleduje křivku.", + "help.solid.thread": "Přidá metrický závit ISO na válcovou plochu, buď jako kosmetický závit, nebo vyříznutý do tělesa. Použijte to pro šrouby, závitové otvory a tištěné šroubové díly.", + "help.solid.torus": "Vytvoří anuloid, tedy prstenec, z hlavního poloměru kolem jeho osy a vedlejšího poloměru jeho kulatého průřezu. Použijte to pro O-kroužky, držadla a kulaté korálky.", + "help.solid.web": "Přidá tenkou stěnu podél každé otevřené křivky náčrtu, každou vyplněnou až k nejbližším plochám tělesa. Použijte to pro mřížku podpůrných stěn uvnitř krytů a táců.", + "help.surface.blend": "Vytvoří plochu, která překlenuje hrany dvou těles, tečně nebo spojitě v křivosti k oběma. Použijte to k hladkému spojení dvou ploch přes mezeru.", + "help.surface.boundary": "Vytvoří plochu vyplňující smyčku dvou až čtyř křivek náčrtu, nebo síť křivek. Použijte to pro volné panely dané svými hranami.", + "help.surface.extend": "Prodlouží plochu za její hrany o vzdálenost nebo až k rovině či ploše. Použijte to, aby se plochy před oříznutím nebo sešitím překrývaly.", + "help.surface.extrude": "Vysune křivky nebo profily náčrtu o vzdálenost do plochy, jen stěny, bez čel. Použijte to pro tenké stěny a pro řezné či dělicí nástroje.", + "help.surface.fair": "Vyhladí vnitřek volných ploch, zatímco jejich hrany zůstanou na místě. Použijte to k vyrovnání nerovností a vlnitých odrazů na ploše.", + "help.surface.loft": "Vytvoří plochu, která prochází dvěma nebo více průřezy v pořadí, bez čel. Použijte to pro skořepiny a přechody mezi různými tvary.", + "help.surface.match": "Přestaví hranu plochy tak, aby se setkala s hranou jiného tělesa, se shodnou tečností nebo křivostí. Použijte to k hladkému uzavření spoje mezi dvěma plochami.", + "help.surface.offset": "Vytvoří kopii ploch ve vzdálenosti podél jejich normál jako nové plošné těleso. Použijte to pro povlaky, vůle nebo druhou stranu stěny.", + "help.surface.patch": "Vyplní oblast náčrtu nebo uzavřenou smyčku hran plochou, rovnou nebo hladce zakřivenou. Použijte to k uzavření děr v plošném tělese.", + "help.surface.reverse_normal": "Obrátí plochy plošného tělesa, takže jejich vnější strana směřuje na druhou stranu. Použijte to před zesílením nebo sešitím, když plochy míří dovnitř.", + "help.surface.revolve": "Otočí křivky nebo profily náčrtu kolem osy do plochy, bez čel. Použijte to pro kulaté skořepiny, například stínidla, mísy a trysky.", + "help.surface.ruled": "Vytvoří pás přímek vycházejících z řetězce hran, podél normály plochy, tečně nebo pod úhlem. Použijte to pro úkosové stěny, náběhy a dělicí plochy.", + "help.surface.stitch": "Sešije plošná tělesa podél sdílených hran a pokud se uzavřou, vznikne těleso. Použijte to k přeměně množiny ploch na jedno těleso.", + "help.surface.sweep": "Posune křivky nebo profily náčrtu po cestě do plochy, bez čel. Použijte to pro žlaby, lišty a jiné tenké tvary, které sledují cestu.", + "help.surface.thicken": "Dá plošnému tělesu tloušťku a změní je na těleso. Použijte to k vytvoření tisknutelných nebo obrobitelných dílů z ploch.", + "help.surface.trim": "Řízne plochu rovinou, jinou plochou nebo křivkami náčrtu a ponechá stranu, kterou vyberete. Použijte to k oříznutí ploch do tvaru před sešitím.", + "help.surface.unstitch": "Odděluje plochy tělesa do samostatných plošných těles; těleso se rozpadne na plochy. Použijte to k úpravě jednotlivých ploch tělesa.", + "help.surface.untrim": "Odstraní z plochy otvory, nebo obnoví její vnější hranici na přirozené meze povrchu. Použijte to k získání celé plochy zpět po oříznutí.", "help.third_party": "Licence třetích stran", "help.timeline.compute_all": "Přepočítá všechny prvky na časové ose od začátku. Použijte to k obnovení modelu a ke zjištění, které prvky selhávají.", "help.timeline.delete": "Smaže vybrané prvky z časové osy; smazáním náčrtu se smažou i prvky z něj vytvořené. Použijte to k odstranění kroku, který už nechcete.", + "help.timeline.dependents": "Vypíše prvky, které by se smazaly spolu s prvkem, a ty, které by bez něj selhaly. Použijte to před smazáním nebo změnou prvku.", "help.timeline.play": "Přehraje zbývající prvky jeden po druhém s půlsekundovou pauzou po každém přestavění. Spuštěním tohoto příkazu znovu nebo stisknutím Escape přehrávání zastavíte; Přejít na konec skočí okamžitě.", + "help.view.apply": "Natočí pohled do standardního směru, například zepředu, shora nebo izometricky, nebo do uloženého pojmenovaného pohledu. Použijte to k prohlížení modelu z pevného úhlu.", + "help.view.delete": "Smaže uložený pojmenovaný pohled. Model se nezmění.", + "help.view.fit": "Přiblíží a posune pohled tak, aby celý model nebo výběr vyplnil pohled. Použijte to, když model zmizel z dohledu.", + "help.view.grid": "Zobrazí nebo skryje mřížku v pohledu. Použijte ji jako vodítko velikosti a orientace při modelování.", + "help.view.ground_shadow": "Zobrazí nebo skryje stín modelu na podložce. Použijte to k snazšímu posouzení výšky a polohy.", + "help.view.home": "Vrátí pohled do výchozího směru a přizpůsobí model. Použijte to k rychlému návratu do známého pohledu.", + "help.view.list": "Vypíše pojmenované pohledy uložené v návrhu. Použijte to k nalezení dříve uloženého pohledu.", + "help.view.navigate": "Natočí, posune nebo přiblíží pohled po krocích, například o čtvrt otáčky doleva. Použijte to k přesnému pohybu kamery místo tažení.", + "help.view.navigation_bar": "Zobrazí nebo skryje navigační lištu ve spodní části pohledu. Skryjte ji pro více místa, zobrazte ji pro přístup k nástrojům pohledu myší.", + "help.view.rename": "Dá uloženému pojmenovanému pohledu nový název. Samotný pohled zůstane stejný.", + "help.view.save": "Uloží aktuální polohu kamery jako pojmenovaný pohled v návrhu. Použijte to k pozdějšímu návratu ke stejnému úhlu, například pro snímky obrazovky.", + "help.view.spin": "Otáčí pohled kolem modelu nepřetržitě až do dalšího kliknutí nebo stisku klávesy. Použijte to k prezentaci modelu ze všech stran.", + "help.view.tool": "Zapne nástroj kamery, otáčení, posun nebo přiblížení, takže tažení levým tlačítkem myši pohybuje pohledem, dokud jej nevypnete. Použijte to na trackpadu nebo bez prostředního tlačítka myši.", + "help.view.visual_style": "Přepne způsob zobrazení modelu: stínovaný s hranami nebo bez nich, drátový model, nebo se skrytými hranami. Použijte drátový model pro pohled dovnitř, stínovaný pro čistý vzhled.", "history.sketch_under_constrained": "Nedourčeno (stupňů volnosti: {count})", "history.sketch_under_constrained_one": "Nedourčeno ({count} stupeň volnosti)", "hole_presets.confirm_delete": "Potvrdit smazání", @@ -1615,6 +1867,10 @@ "material.selection.mixed": "Různé materiály", "measure.at": "V bodě", "measure.item": "Položka {number}", + "measure.quick_copied": "Měření zkopírováno", + "measure.quick_copy": "Kliknutím zkopírujete měření", + "measure.quick_edges": "Hrany: {count}", + "measure.quick_faces": "Plochy: {count}", "messages.ago_h": "před {n} h", "messages.ago_min": "před {n} min", "messages.ago_s": "před {n} s", @@ -1767,6 +2023,11 @@ "not a move": "nejde o posun", "not a number": "není číslo", "open a sketch first": "nejprve otevřete náčrt", + "parameters.apply_live": "Použít každou změnu po opuštění jejího pole", + "parameters.apply_live_tip": "Vypnuto: změny čekají na Použít nebo OK, pak se návrh přestaví jednou pro všechny. Zrušit nebo Escape je zahodí.", + "parameters.held": "Změny čekající na Použít: {count}", + "parameters.held_hint": "Upravte výrazy a jednotky. Změny čekají na Použít nebo OK.", + "parameters.held_one": "1 změna čeká na Použít", "parts.automatic_name": "automaticky", "parts.dimension.across_flats": "Rozevření klíče", "parts.dimension.bore": "Otvor", @@ -1789,6 +2050,8 @@ "parts.family.set_screw": "Stavěcí šroub s vnitřním šestihranem, s plochým koncem", "parts.family.socket_head_cap_screw": "Šroub s válcovou hlavou a vnitřním šestihranem", "parts.family.washer": "Plochá podložka", + "parts.holes": "Otvory (volitelné)", + "parts.placement_hint": "vyberte jeden nebo více otvorů, aby se do každého usadil díl, nebo OK umístí díl do počátku", "pattern.alignment_2": "Zarovnání 2", "pattern.alignment_hint": "Počty zahrnují originál. Na střed rozloží kopie rovnoměrně; Převrátit volí stranu navíc u sudých počtů.", "pattern.direction_1": "Směr 1", @@ -1955,6 +2218,8 @@ "prefs.origin_plane_names": "Názvy rovin počátku", "prefs.origin_plane_names_note": "Názvy zobrazené v prohlížeči, dialozích a seznamech. {top} je XY, {front} je XZ a {right} je YZ, osa Z směřuje nahoru. Příkazy, skripty a uložené návrhy zachovávají XY, XZ a YZ.", "prefs.overall_speed": "Celková rychlost", + "prefs.parameter_list_rows": "Řádky seznamu parametrů", + "prefs.parameter_list_rows_tip": "Šipka dolů v prázdném poli hodnoty zobrazí seznam parametrů, nejprve oblíbené, nejvýše v tomto počtu. Menší počet udrží seznam přehledný ve velkém návrhu.", "prefs.persistent_ribbon_pressed": "Zvýrazňovat aktivní příkazy na pásu karet", "prefs.persistent_ribbon_pressed_help": "Zvýrazní příkazy na pásu karet, dokud jsou jejich účinky zapnuté. Vypnutím zachováte dřívější neutrální vzhled a nápovědu k příkazům.", "prefs.perspective_fov": "Zorný úhel", @@ -1984,6 +2249,8 @@ "prefs.quality_fastest": "nejrychlejší", "prefs.quality_slowest": "nejpomalejší", "prefs.quality_smoother": "hladší", + "prefs.quick_measure": "Rychlé měření v rohu pohledu", + "prefs.quick_measure_note": "Velikost výběru v pravém dolním rohu pohledu: délka hrany, nebo Ø a R u kulaté hrany, obsah plochy, poloha bodu, vzdálenost a úhel dvou položek, celkový součet u několika hran nebo ploch. Kliknutím ji zkopírujete.", "prefs.render_previous_geometry_budget": "Použít předchozí rozpočet geometrie náhledu renderu", "prefs.render_previous_geometry_budget_help": "Obnoví předchozí pevný limit geometrie (512 MiB na 64bitovém desktopu, 128 MiB jinde) místo rozpočtu podle zařízení.", "prefs.reports_saved": "{n} uloženo", @@ -2029,6 +2296,8 @@ "prefs.sketch_tab_always_note": "Karta Náčrt zůstává na pásu karet hned za kartou Model, i když není otevřený žádný náčrt. Její kreslicí nástroje pak zahájí náčrt na vybrané rovině nebo ploše, nebo na rovině, kterou vyberete jako další, a nástroj je v něm rovnou připraven.", "prefs.sketch_tracking_guides": "Vodítka zarovnání ke středům a středním bodům", "prefs.sketch_tracking_guides_note": "Najeďte na střed kružnice nebo střed rovné hrany a poté se pohybujte vodorovně nebo svisle, aby se zobrazilo dočasné tečkované vodítko.", + "prefs.sketch_tracking_points": "Vodítka zarovnání z koncových bodů a rohů při kreslení", + "prefs.sketch_tracking_points_note": "Když nástroj kreslení čeká na bod, najetí na konec úsečky nebo oblouku, roh obdélníku nebo volný bod ukáže stejné dočasné tečkované vodítko jako střed nebo střední bod. Vypnuto: jen středy a střední body.", "prefs.sketching": "Kreslení", "prefs.snap_drawing_note": "Při kreslení nebo přetahování bodu náčrtu.", "prefs.source": "Zdroj", @@ -2056,6 +2325,16 @@ "prefs.theme_follow_off": "Vypnuto", "prefs.theme_follow_stop": "Ukončit sledování", "prefs.theme_import_hint": "Kliknutím vyberte soubor motivu, přetáhněte ho sem, nebo stiskněte Ctrl+V (na Macu Cmd+V) se zkopírovaným motivem", + "prefs.theme_option_opacity": "Neprůhlednost", + "prefs.theme_option_opacity_help": "Upravte neprůhlednost plovoucích panelů. Minimální čitelnost chrání text nad barvami pohledu.", + "prefs.theme_option_tab_colours": "Barvy karet", + "prefs.theme_option_tab_colours_help": "Použít pro každou kartu pásu karet barvy panelů a pohledu tohoto motivu.", + "prefs.theme_option_tab_fade_ms": "Přechod karet", + "prefs.theme_option_tab_fade_ms_help": "Prolnout barvy panelů a pohledu při přepínání karet. Nula přepíná okamžitě.", + "prefs.theme_options_badge": "Možnosti", + "prefs.theme_options_more": "Více · {count}", + "prefs.theme_options_reset": "Obnovit", + "prefs.theme_options_unavailable": "Na tomto zařízení nedostupné", "prefs.theme_paste": "Vložit motiv", "prefs.theme_paste_none": "Schránka neobsahuje žádný motiv SolidCraft", "prefs.timeline_half_hour": "−30 min", @@ -2077,6 +2356,8 @@ "prefs.under_cursor": "Pod kurzorem", "prefs.units_precision": "Jednotky a přesnost", "prefs.updates": "Aktualizace", + "prefs.value_boxes_in_view": "Zobrazovat hodnoty vedle jejich úchytů v pohledu", + "prefs.value_boxes_in_view_tip": "Hodnota příkazu (Vzdálenost, Poloměr…) v rámečku vedle jeho úchytu nad modelem. Vypnuto: zadáváte ji v okně příkazu, které po výběru převezme klávesnici, a nic nezakrývá model.", "prefs.view": "Chování pohledu", "prefs.view_confirm_corner": "OK a Zrušit v rohu pohledu", "prefs.view_confirm_corner_note": "Když je otevřen dialog nebo náčrt, zaškrtnutí a křížek vpravo nahoře v pohledu provedou totéž co OK a Zrušit v dialogu, nebo náčrt dokončí a zruší. Navigátor pohledu se posune pod ně.", @@ -2086,6 +2367,12 @@ "prefs.viewport_background": "Pozadí", "prefs.viewport_background_colour": "Barva", "prefs.viewport_background_theme": "Přechod motivu", + "prefs.viewport_section_status": "Zobrazovat stav řezu v pohledu", + "prefs.viewport_section_status_help": "Zobrazí nastavení řezu v pohledu. Escape a aktivní tlačítko na pásu karet ho vypnou. Vypnutím zůstanou dosavadní ovládací prvky řezu.", + "prefs.viewport_status_details": "Živé podrobnosti pohledu", + "prefs.viewport_status_details_help": "Upravujte nastavení aktivní analýzy a řezu ve standardních kartách podrobností. Vypnutím obnovíte klávesy analýzy jen pro čtení a dialog Řez.", + "prefs.viewport_status_summary": "Sbalit stavové položky pohledu", + "prefs.viewport_status_summary_help": "Sbalí tři nebo více stavových položek pohledu do jednoho souhrnného štítku. Vypnutím zůstanou všechny položky v plné řadě.", "prefs.wheel": "Kolečko", "prefs.wheel_trackpad": "Kolečko a trackpad", "prefs.workspace": "Pracovní prostředí", @@ -2964,7 +3251,9 @@ "render.output_ready": "Připraveno k vykreslení", "render.output_run_check": "Spustit kontrolu", "render.output_size_limit": "{method}: nejvýše {dimension} px na stranu a {pixels} pixelů. Největší vyhovující velikost: {width} × {height}.", + "render.output_size_method_profile": "Pro vykreslení {width} × {height} zvolte {method} s profilem prostředků CPU {profile}, nebo menší velikost.", "render.output_size_ok": "Velikost je v pořádku · {width} × {height} px", + "render.output_size_profile": "Pro vykreslení {width} × {height} zvolte profil prostředků CPU {profile}, nebo menší velikost.", "render.output_swap_orientation": "Prohodit šířku a výšku", "render.output_tools_inspector": "Velikost a formát upravte v inspektoru Výstup.", "render.output_view_image": "Zobrazit obrázek", @@ -3177,6 +3466,7 @@ "render.size_custom": "Vlastní…", "render.size_full_hd": "Full HD", "render.size_hd": "HD", + "render.size_needs_profile": "{size} · vyžaduje {profile}", "render.size_presets": "Předvolby velikosti", "render.size_presets_pending": "Přijde s editorem Výstup", "render.size_preview": "Náhled", @@ -3464,6 +3754,24 @@ "sheet.flange_type_contour": "Kontura", "sheet.flange_type_edge": "Hrana", "sheet.no_body": "není žádné plechové těleso: začněte příkazem Příruba > Základ, nebo změňte desku na plech příkazem Převést na plech (vyberte její velkou rovnou plochu)", + "sheet.personal_rules.confirm_delete": "Potvrdit smazání", + "sheet.personal_rules.delete": "Smazat", + "sheet.personal_rules.delete_prompt": "Smazat toto osobní pravidlo? Pravidla, která už jsou v návrzích, se nezmění.", + "sheet.personal_rules.full": "Osobní knihovna už obsahuje 64 pravidel.", + "sheet.personal_rules.invalid_name": "Zadejte krátký název bez zalomení řádků a řídicích znaků.", + "sheet.personal_rules.invalid_values": "Doplňte hodnoty pravidla: všechny se musí v tomto návrhu dát vypočítat, tloušťka musí být kladná a K-faktor od 0 do 1.", + "sheet.personal_rules.missing": "Vybrané osobní pravidlo už není k dispozici.", + "sheet.personal_rules.name": "Název pravidla", + "sheet.personal_rules.name_exists": "Osobní pravidlo s tímto názvem už existuje. Použijte jiný název.", + "sheet.personal_rules.name_hint": "Jedinečný název", + "sheet.personal_rules.overwrite": "Uložit změny do vybraného osobního pravidla", + "sheet.personal_rules.rename": "Přejmenovat", + "sheet.personal_rules.rule": "Osobní pravidlo", + "sheet.personal_rules.save": "Uložit jako nové", + "sheet.personal_rules.scope": "Uloženo ve vašem profilu pro všechny návrhy.", + "sheet.personal_rules.select": "Vyberte pravidlo", + "sheet.personal_rules.title": "Osobní pravidla", + "sheet.personal_rules.unsupported": "Toto pravidlo má hodnoty, které tato verze nedokáže použít.", "shortcut.json_error": "parametry musí být ve formátu JSON: {error}", "shortcut.no_command": "příkaz `{id}` neexistuje; text před JSON musí být ID příkazu", "shortcut.pin": "Připnout nahoru do pole S", @@ -3500,6 +3808,7 @@ "sketch_text.align_right": "Vpravo", "sketch_text.bold": "Tučné", "sketch_text.builtin_font": "{family} (vestavěné)", + "sketch_text.formula": "Začněte znakem = a sestavte slova z parametrů: ='W' + number(Width)", "sketch_text.italic": "Kurzíva", "sketch_text.loading_fonts": "Hledání nainstalovaných písem…", "sketch_text.on_path": "Text sleduje svou cestu: umístění určuje křivka", @@ -3640,6 +3949,8 @@ "ui.3d_mouse_section": "3D myš", "ui.3d_mouse_show_in_view": "Zobrazit 3D myš ve 3D pohledu", "ui.3d_mouse_show_in_view_tip": "Obrázek vaší 3D myši v levém dolním rohu, který se hýbe s ní.", + "ui.3d_mouse_sketch_orbit": "Otáčet náčrty 3D myší", + "ui.3d_mouse_sketch_orbit_tip": "3D myš otáčí pohled i při otevřeném náčrtu, na který se díváte zpříma, stejně jako to umí myš. Vypnuto: náčrt posouvá a přibližuje, dokud pohled nenakloníte.", "ui.3d_mouse_skip": "Moje zařízení to neumí", "ui.3d_mouse_skip_tip": "Přeskočí tento pohyb v obou směrech a kalibrace ho vynechá.", "ui.3d_mouse_speed": "Rychlost 3D myši", @@ -3708,6 +4019,7 @@ "ui.agent_chat_auth_terminal": "Spusťte toto v terminálu a pak znovu stiskněte Připojit:", "ui.agent_chat_auth_title": "Přihlásit se k agentovi", "ui.agent_chat_cancel": "Zrušit", + "ui.agent_chat_cannot_start": "Nainstalováno, ale nespouští se: {error}", "ui.agent_chat_changes": "Změny: {n}, jeden krok zpět", "ui.agent_chat_changes_n": "Změn: {n}", "ui.agent_chat_changes_of": "Změny: {n}/{limit}", @@ -3800,9 +4112,12 @@ "ui.agent_chat_queued_stopped": "Ve frontě (zastaveno, neodesláno)", "ui.agent_chat_refresh": "Obnovit", "ui.agent_chat_refused": "Pro agenta nepovoleno: {reason}", + "ui.agent_chat_reinstall": "{agent} je nainstalován ({program}), ale při kontrole SolidCraftu se nespustil: {error}\nJeho instalace může být neúplná nebo vytvořená pro jiný typ počítače. Nainstalujte jej znovu příkazem `{install}`, ověřte v terminálu, že vypíše svou verzi, a pak stiskněte Obnovit.", "ui.agent_chat_remove": "Odebrat z fronty", "ui.agent_chat_remove_file": "Odebrat z této zprávy", "ui.agent_chat_remove_imported": "Odebrat z této zprávy; díl zůstane v návrhu (Zpět jej odstraní)", + "ui.agent_chat_reply_cut_off": "Odpověď dosáhla limitu délky dříve, než skončila, takže může být neúplná. Požádejte agenta, aby pokračoval, nebo o kratší odpověď.", + "ui.agent_chat_reply_declined": "Model odmítl na tuto zprávu odpovědět.", "ui.agent_chat_retry": "Zkusit znovu", "ui.agent_chat_right": "Vpravo", "ui.agent_chat_save": "Uložit", @@ -3861,12 +4176,17 @@ "ui.ai_copy_suffix": "kopie", "ui.ai_custom_url": "Vlastní URL", "ui.ai_default": "Výchozí", + "ui.ai_edit": "Upravit", + "ui.ai_edit_agent_title": "Upravit agenta", + "ui.ai_edit_gone": "Tento agent byl odebrán, takže není co ukládat.", + "ui.ai_edit_tip": "Změní název, příkaz nebo endpoint tohoto agenta. Uložený klíč zůstane skrytý.", "ui.ai_http_busy": "endpoint je zaneprázdněný nebo překročil limit; zkuste to později", "ui.ai_http_key": "endpoint odmítl klíč: zkontrolujte jej v Předvolby > AI", "ui.ai_http_not_found": "takový endpoint nebo model neexistuje: zkontrolujte základní URL a název modelu", "ui.ai_http_too_large": "konverzace je pro tento endpoint příliš velká; začněte nový chat", "ui.ai_key": "API klíč", "ui.ai_key_hint": "volitelné; uloženo v systémové klíčence", + "ui.ai_key_keep_hint": "nechte prázdné, aby se zachoval uložený klíč", "ui.ai_key_needs_https": "Klíč se odesílá pouze přes HTTPS nebo na tento počítač.", "ui.ai_key_note": "Ukládá se v systémové klíčence, nikdy v nastavení SolidCraftu. Pro místní server nechte prázdné.", "ui.ai_keychain_error": "Systémová klíčenka odmítla: {error}", @@ -3898,6 +4218,7 @@ "ui.ai_request_timeout": "Časový limit požadavku", "ui.ai_request_timeout_hint": "Jak dlouho smí trvat jeden požadavek na model tohoto endpointu, od 30 s do hodiny. U pomalého místního modelu jej zvyšte.", "ui.ai_reset": "Obnovit", + "ui.ai_save": "Uložit", "ui.ai_settings": "Nastavení AI…", "ui.ai_show_features": "Zobrazit funkce AI", "ui.ai_show_features_note": "Chat s agentem: panel, ve kterém na otevřeném návrhu pracuje kódovací agent, kterého již používáte, nebo model na zvoleném endpointu. Vypnuto: žádné tlačítko AI, zkratka, položka nabídky ani panel a nic se nehledá ani nespouští.", @@ -4071,6 +4392,40 @@ "ui.design_properties.project": "Projekt", "ui.design_properties.revision": "Revize", "ui.design_saved": "Uloženo do {path}", + "ui.details_access_caption": "Plochy ukazují, zda se k nim nástroj dostane ze zvoleného směru.", + "ui.details_angle": "Úhel", + "ui.details_continuity_caption": "Upravte vybraný spoj. Ostatní spoje si ponechají svá nastavení.", + "ui.details_continuity_stale": "Dřívější geometrie: spusťte analýzu znovu, abyste mohli tento spoj upravit nebo zarámovat.", + "ui.details_copy_list": "Kopírovat seznam", + "ui.details_curvature": "Tolerance křivosti", + "ui.details_curvature_caption": "Plochy se barví podle místního poloměru křivosti.", + "ui.details_direction": "Směr nástroje", + "ui.details_draft_caption": "Plochy se barví podle úhlu ke směru vyjímání.", + "ui.details_edit_seam": "Upravit spoj", + "ui.details_fixture": "Ukázková komponenta — pouze vzorová data", + "ui.details_fixture_caption": "Ukázková komponenta s ilustračními hodnotami; nejde o výsledek modelu.", + "ui.details_fixture_problem": "Příklad nálezu s problémem", + "ui.details_fixture_readout": "Příklad naměřené hodnoty", + "ui.details_flip": "Převrátit", + "ui.details_gap": "Mezera", + "ui.details_hatch": "Šrafovat řezné plochy", + "ui.details_jump": "Skok křivosti", + "ui.details_key": "Legenda", + "ui.details_offset": "Odsazení", + "ui.details_plane": "Rovina", + "ui.details_pull": "Směr vyjímání", + "ui.details_radius": "Poloměr", + "ui.details_readout": "Odečet", + "ui.details_samples": "Vzorky", + "ui.details_seam": "{body} · Hrana {edge}", + "ui.details_settings": "Nastavení", + "ui.details_sharp_gap": "Zlom nebo mezera", + "ui.details_show_all": "Zobrazit všechny ({count})", + "ui.details_smooth": "Hladký", + "ui.details_stripes": "Pruhy", + "ui.details_target": "Zarámovat geometrii", + "ui.details_worst_seams": "Nejhorší spoje", + "ui.details_zebra_caption": "Odražené pruhy ukazují, jak hladce plochy navazují.", "ui.diameter": "Průměr", "ui.dimension": "Kóta", "ui.direct_interaction": "Přímá interakce", @@ -4131,6 +4486,7 @@ "ui.equation_y_of_x": "y(x)", "ui.equivalent_radius": "Ekvivalentní poloměr", "ui.expand": "Rozbalit", + "ui.explode_needs_two_components": "Rozložený pohled odtahuje komponenty od sebe, takže návrh potřebuje dvě nebo více komponent (Nová komponenta, Vložit návrh nebo Vytvořit komponenty z těles)", "ui.exportDxf": "Exportovat DXF…", "ui.export_ellipsis_keymap": "Exportovat…", "ui.export_keymap_tip": "Klávesy, oblíbené z vyhledávání S a panel nástrojů jako soubor", @@ -4419,12 +4775,17 @@ "ui.plugins_skipped": "Složek, které nešlo přečíst: {count}.", "ui.plugins_version": "{version}", "ui.point": "Bod", + "ui.point_cloud_add_filter": "Přidat filtr…", "ui.point_cloud_auto_range": "Automatický rozsah", "ui.point_cloud_color_by": "Barva podle", "ui.point_cloud_file_colors": "Barvy ze souboru", + "ui.point_cloud_filter_by": "Filtrovat podle", "ui.point_cloud_height_high": "Nejvyšší", "ui.point_cloud_height_low": "Nejnižší", + "ui.point_cloud_legend_filter": "Zobrazeno a skryto v legendě výše", "ui.point_cloud_no_file_colors": "Soubor neobsahuje žádné barvy", + "ui.point_cloud_pause": "Pozastavit", + "ui.point_cloud_play": "Přehrát", "ui.point_cloud_single_color": "Jedna barva", "ui.point_cloud_size": "Velikost bodů", "ui.point_cloud_units": "Jednotky souboru", @@ -4453,6 +4814,7 @@ "ui.properties.inertia_y": "Iyx, Iyy, Iyz", "ui.properties.inertia_z": "Izx, Izy, Izz", "ui.properties.measuring": "Měření…", + "ui.properties.measuring_bodies": "Měření… {done} z {total} těles", "ui.properties.measuring_failed": "Měření se zastavilo před dokončením.", "ui.properties.principal_axis": "Hlavní osa {number}", "ui.properties.principal_moments": "Hlavní momenty setrvačnosti", @@ -4520,6 +4882,7 @@ "ui.selectOther": "Vybrat jiné…", "ui.selectTangentChain": "Vybrat tečný řetězec", "ui.select_all": "Vybrat vše", + "ui.select_components_by_face": "Vybírat komponenty (plocha vybere svou komponentu)", "ui.selection": "Výběr", "ui.selection_modifiers": "Modifikátory výběru", "ui.selection_shift_add_only": "Shift+klik pouze přidává", @@ -4561,6 +4924,11 @@ "ui.sketch_intersect_selection_filter": "Filtr výběru", "ui.sketch_modifiers": "Modifikátory náčrtu", "ui.sketch_project_geometry": "Geometrie", + "ui.sketch_report.all_constrained": "Vše je plně zavazbeno.", + "ui.sketch_report.dof": "Stupně volnosti", + "ui.sketch_report.loose": "Není plně zavazbeno", + "ui.sketch_report.status": "Stav", + "ui.sketch_report.title": "Zpráva o náčrtu", "ui.sketch_scale": "Měřítko náčrtu", "ui.sketches": "Náčrty", "ui.slice": "Řez", @@ -4670,7 +5038,14 @@ "ui.viewport_status_key": "Zobrazit legendu pro {name}", "ui.viewport_status_minimum_radius": "Minimální poloměr", "ui.viewport_status_pairs": "Dvojice: {count}", + "ui.viewport_status_section_hatch": "{setting} · šrafování", + "ui.viewport_status_section_plane": "Aktuální rovina", + "ui.viewport_status_section_tilted": "nakloněno · {offset}", "ui.viewport_status_stripes": "Pruhy: {count}", + "ui.viewport_status_summary_clear": "Vypnout vše", + "ui.viewport_status_summary_heading": "V tomto pohledu", + "ui.viewport_status_summary_label": "{name} a další položky: {count}", + "ui.viewport_status_summary_more": "+{count}", "ui.viewport_theme_follow": "Podle motivu", "ui.visual_shaded_hidden_edges": "Stínovaný se skrytými hranami", "ui.visual_shaded_with_edges": "Stínovaný s hranami", diff --git a/assets/locales/da.json b/assets/locales/da.json index 0e86005347..b6a1e56fda 100644 --- a/assets/locales/da.json +++ b/assets/locales/da.json @@ -60,6 +60,7 @@ "BLEND SURFACE": "OVERGANGSFLADE", "BOUNDARY SURFACE": "AFGRÆNSET OVERFLADE", "BROWSER": "MODELTRÆ", + "Back to the original order": "Tilbage til den oprindelige rækkefølge", "Ball": "Kugleled", "Bar": "Stang", "Base Center": "Bundens centrum", @@ -595,6 +596,8 @@ "Snap to face and edge centres": "Snap til flade- og kantcentre", "SolidCraft cannot submit reports directly. Submit on GitHub; no token is needed in the app.": "SolidCraft kan ikke indsende rapporter direkte. Indsend dem på GitHub; der skal ikke bruges noget token i appen.", "SolidCraft closed before these designs were saved. Their last autosave can be opened.": "SolidCraft lukkede, før disse designs blev gemt. Den seneste automatiske lagring kan åbnes.", + "Sort A to Z": "Sortér A til Å", + "Sort Z to A": "Sortér Å til A", "Spacing 2 must be a non-zero number": "Afstand 2 skal være et tal forskelligt fra nul", "Spacing must be a non-zero number": "Afstand skal være et tal forskelligt fra nul", "Specialise": "Specialisér", @@ -819,6 +822,7 @@ "cmd.appearance_pick_up": "Optag udseende", "cmd.appearance_replace": "Erstat udseende", "cmd.assembly_bom": "Stykliste", + "cmd.assembly_mass_properties": "Masseegenskaber", "cmd.body.print_face": "Printflade", "cmd.body_rename": "Omdøb emne", "cmd.browser_collapse_all": "Fold alle sammen", @@ -1244,6 +1248,7 @@ "cmd.view.render_browser": "Renderingsbrowser", "cmd.view.render_dock": "Flyt renderingsdock", "cmd.view.render_focus": "Fokusvisning af rendering", + "cmd.view.status_details": "Detaljer om visningsområdets status", "cmd.view.status_items": "Statuselementer i visningsområdet", "cmd.view_ambient_occlusion": "Ambient occlusion", "cmd.view_angle_readout": "Visningsvinkler", @@ -1387,21 +1392,168 @@ "from the rule": "fra reglen", "help.apache": "Apache-licens 2.0", "help.appearance.assign": "Udseende giver de valgte emner, flader eller komponenter en farve eller et materialeudseende uden at ændre deres vægt. Brug det til at skelne dele fra hinanden eller til at se en overflade på forhånd.", + "help.assembly.bom": "Viser delene i designet eller en komponent med deres antal efter varenummer og kan gemme listen som CSV eller JSON. Brug den som stykliste til indkøb og montage.", "help.attributions": "Tilskrivninger (ATTRIBUTION.md)", "help.build": "Version {version} · commit {commit} · bygget {date}", + "help.canvas.decal": "Placerer et billede på en plan flade af modellen som en dekal. Brug den til at vise en etiket, et logo eller et trykt mønster på en del.", + "help.canvas.edit": "Ændrer et referencebillede: dets position, størrelse, vinkel, opacitet, spejling, synlighed eller navn. Brug den til at lægge et referencebillede op ad den skitse, du tegner efter.", + "help.canvas.insert": "Placerer et PNG- eller JPEG-billede på et plan eller en flade som gennemsigtig reference. Brug den til at tegne efter et foto eller en tegning i en skitse, hvor billedet er skaleret til en kendt størrelse.", + "help.component.break_link": "Løsriver en linket komponent fra dens designfil, så emnerne beholdes, som de er. Brug den, når delen ikke længere skal følge ændringer i originalfilen.", + "help.component.capture_position": "Registrerer positionerne af dele, du har flyttet i hånden, så de bliver delenes placering. Brug den, når du har trukket dele på plads, som intet led holder fast.", + "help.component.create": "Tilføjer en ny, tom komponent til designet inde i den aktive komponent, medmindre du vælger en anden overordnet komponent. Brug den til at give en ny del af en samling sin egen plads til emner og skitser.", "help.component.finish_edit": "Afslut redigering gemmer den del, du redigerer på stedet, vender tilbage til dens samling og opdaterer samlingen ud fra den. Brug den, når dine ændringer af den tilknyttede del er færdige.", + "help.component.from_bodies": "Gør hvert valgt emne til sin egen komponent opkaldt efter emnet. Brug den, når et design bygget af løse emner skal blive en samling, hvis dele kan forbindes og flyttes.", + "help.component.insert_design": "Indsætter et andet gemt design som en komponent, der er linket til dets fil, med dets massive emner placeret som i originalen. Brug den til at genbruge en del i en samling og hente senere ændringer med Opdatér forbindelser.", + "help.component.properties": "Viser volumen, masse, massecentrum og inertimomenter for en komponent ud fra emnernes materialer. Brug den til at kontrollere vægt og balance for en samling.", + "help.component.revert_position": "Sætter dele, du har flyttet i hånden, tilbage, hvor de var, og kasserer flytninger, der ikke er registreret. Brug den til at fortryde prøveflytninger i en samling.", + "help.component.update_links": "Læser de linkede designfiler igen og erstatter emner, der er ændret. Brug den, når du har redigeret en del, som samlingen bruger.", + "help.config.configure": "Tilføjer parametre, features, materialer eller udseender som kolonner i konfigurationstabellen. Brug den til at lave varianter af ét design, f.eks. størrelser af en del.", + "help.config.preview": "Kontrollerer, at en række i konfigurationstabellen kan genopbygges, uden at designet eller dets historik ændres. Brug den, før du skifter til en variant, du er i tvivl om.", + "help.config.rules": "Viser konfigurationstabellens regler, dens rækker og hvilken række der er aktiv. Brug den til at se, hvordan et designs varianter er sat op.", + "help.config.table": "Viser konfigurationstabellen: én række pr. variant, én kolonne pr. parameter eller feature og om designet stadig svarer til den aktive række. Brug den til at gennemgå et designs varianter.", "help.connect_note": "Genstart hver assistent for at indlæse SolidCraft-serveren. Hver fil blev sikkerhedskopieret som .bak, før den blev ændret.", "help.connect_title": "Forbind AI-assistenter", + "help.construct.axis.edge": "Opretter en konstruktionsakse langs en ret kant eller skitselinje. Brug den som akse til en omdrejning, et cirkulært mønster eller et led.", + "help.construct.axis.normal_to_face": "Opretter en konstruktionsakse gennem et punkt, vinkelret på en flade. Brug den som en akse, der står lige ud fra en overflade.", + "help.construct.axis.through_cylinder": "Opretter en konstruktionsakse gennem midten af en cylinder-, kegle- eller torusflade eller en cirkulær kant. Brug den til at dreje eller gentage features omkring en rund del.", + "help.construct.axis.two_planes": "Opretter en konstruktionsakse, hvor to planer eller plane flader mødes. Brug den, når den ønskede akse er linjen mellem to flader.", + "help.construct.axis.two_points": "Opretter en konstruktionsakse gennem to punkter, hjørnepunkter eller skitsepunkter. Brug den til en akse mellem to kendte positioner.", + "help.construct.plane.along_path": "Opretter et konstruktionsplan vinkelret på en kant eller kurve i en given position langs den. Brug det til at skitsere en profil til et sweep eller et snit af en bane.", + "help.construct.plane.at_angle": "Opretter et konstruktionsplan, der er drejet med en vinkel fra et plan eller en flade omkring en akse eller kant. Brug det til skrå skitser, f.eks. et vinklet beslag.", + "help.construct.plane.midplane": "Opretter et konstruktionsplan midt mellem to planer eller plane flader eller halverende dem, hvor de mødes i en vinkel. Brug det som symmetriplan til spejling.", + "help.construct.plane.offset": "Opretter et konstruktionsplan parallelt med et plan eller en plan flade i en afstand eller gennem et punkt. Brug det til at skitsere i en bestemt højde over en flade.", + "help.construct.plane.perpendicular": "Opretter et konstruktionsplan, der indeholder en linje eller kant og står vinkelret på et plan eller en flade. Brug det til at skitsere opretstående på en model langs en kant.", + "help.construct.plane.tangent": "Opretter et konstruktionsplan, der berører en cylinder, kegle, kugle eller torus i et punkt. Brug det til at skitsere på siden af en rund del.", + "help.construct.plane.three_points": "Opretter et konstruktionsplan gennem tre punkter. Brug det til et plan, der er givet ved positioner på modellen, f.eks. tre hjørner.", + "help.construct.plane.two_edges": "Opretter et konstruktionsplan gennem to rette kanter eller linjer, der ligger i samme plan. Brug det til et plan på tværs af en del, f.eks. gennem to modstående kanter.", + "help.construct.point.along_path": "Opretter et konstruktionspunkt i en position langs en kant eller kurve. Brug det som reference et stykke henne ad en bane.", + "help.construct.point.center": "Opretter et konstruktionspunkt i midten af en cirkulær kant, en skitsecirkel eller -bue, en kugle eller en torus. Brug det som reference til huller, akser og led.", + "help.construct.point.edge_plane": "Opretter et konstruktionspunkt, hvor en kant eller linje skærer et plan eller en plan flade. Brug det til at markere, hvor en bane krydser et plan.", + "help.construct.point.three_planes": "Opretter et konstruktionspunkt, hvor tre planer eller plane flader mødes. Brug det som hjørnereference, også hvor modellen ikke har noget hjørnepunkt.", + "help.construct.point.two_edges": "Opretter et konstruktionspunkt, hvor to kanter eller linjer mødes eller kommer tættest på hinanden. Brug det som reference i et virtuelt hjørne.", + "help.construct.point.vertex": "Opretter et konstruktionspunkt på et hjørnepunkt eller skitsepunkt. Brug det som reference, der bliver stående, selv om skitsen eller emnet omkring det skjules.", + "help.contact.check": "Viser de par af dele i kontaktsæt, der går gennem hinanden i deres nuværende position. Brug den til at finde kollisioner, før du bevæger led eller kører en bevægelsesanalyse.", + "help.contact.create": "Samler to eller flere dele i et kontaktsæt, hvis emner holdes fra at gå gennem hinanden, mens led bevæger sig. Brug den til dele, der skal stoppe, når de rører hinanden, f.eks. et håndtag mod sit anslag.", + "help.contact.disable_all": "Slår kontakt fra, så ledbevægelser og bevægelsesanalyser ikke længere stopper, hvor dele rører hinanden. Brug den til at flytte dele frit igen eller til at holde en stor samling responsiv.", + "help.contact.edit": "Ændrer et kontaktsæt: de dele, det indeholder, dets navn eller om det er undertrykt; det kan også slette sættet. Brug den til at justere, hvilke dele der holdes fra at gå gennem hinanden.", + "help.contact.enable_all": "Lægger alle samlingens dele i ét kontaktsæt, så ingen to dele kan gå gennem hinanden, mens led bevæger sig. Brug den til et hurtigt tjek af en hel mekanisme.", + "help.contact.enable_sets": "Slår kontakt til for de kontaktsæt, du har defineret, så ledbevægelser og bevægelsesanalyser stopper, hvor deres dele ville gå gennem hinanden. Brug den til at få dine sæt tilbage, efter at du har slået kontakt fra.", + "help.contact.list": "Viser designets kontaktsæt, delene i hvert af dem og om kontakt er slået til. Brug den til at se, hvordan kontakt er sat op, før du bevæger samlingen.", + "help.document.inspect": "Viser et overblik over designet: dets komponenter, emner, skitser, features og parametre. Brug den til at finde rundt i et design, du ikke selv har bygget.", + "help.document.units": "Ændrer de enheder, designet viser længder i, f.eks. mm eller tommer. Geometrien beholder sin størrelse; kun visningen og almindelige tal følger med.", + "help.edit.checkpoint": "Husker designet, den aktive skitse og fortrydelseshistorikken, som de er nu. Brug den, før du prøver noget risikabelt, som du måske vil gå tilbage fra.", + "help.edit.restore_checkpoint": "Går tilbage til et kontrolpunkt, inklusive fortrydelseshistorikken. Brug den til at kassere alt, der er gjort siden kontrolpunktet, i ét trin.", + "help.explode.create": "Opretter en eksploderet visning, der skubber delene væk fra samlingens midte eller med de flytninger, du angiver. Brug den til at vise, hvordan en samling sættes sammen, til vejledninger eller præsentationer.", "help.face.delete": "Slet flade fjerner de valgte flader og lader de omkringliggende flader lukke åbningen, eller lader åbningen stå åben som en overflade. Brug den til at fjerne en afrundet kant, et hul eller en forhøjning, der er i vejen.", + "help.file.insert_mesh": "Tilføjer en 3MF-, STL-, OBJ-, PLY- eller glTF-fil til designet som meshlegemer i en ny komponent. Brug den til at placere en hentet eller scannet model ved siden af dit design som reference.", + "help.file.insert_step": "Tilføjer en STEP-, IGES-, FreeCAD- eller Rhino-fil til designet som massive emner i en ny komponent; en samlings dele bliver til indlejrede komponenter. Brug den til at hente modeller fra leverandører eller andre CAD-programmer.", + "help.file.save_mesh": "Gemmer emner som et STL-, 3MF- eller OBJ-mesh med den glathed af buede flader, du vælger. Brug den til at klargøre dele til 3D-print eller til programmer, der læser mesh.", + "help.file.send_to_slicer": "Gemmer de valgte emner som en 3MF-fil og åbner den i den slicer, du vælger. Brug den til at gå fra modellen til print i ét trin.", "help.font_details": "Brugerfladen bruger egui's indbyggede skrifttyper (Ubuntu Light og Hack, under Ubuntu Font Licence og MIT/Bitstream Vera-licensen; Noto Emoji og emoji-ikonskrifttypen, under OFL og MIT). Skitsetekst bruger Liberation Sans (SIL OFL 1.1). Crafting Apps' valgfrie skrifttypepakke, craft-fonts (BIZ UDPGothic, Shippori Mincho, BIZ UDMincho, Noto Sans CJK SC, Noto Sans Arabic; SIL OFL 1.1), er ikke inkluderet i SolidCraft-builds.", "help.fonts": "Skrifttyper", + "help.frame.member": "Placerer en profil, f.eks. et rør eller en aluminiumsprofil, langs linjerne i en rammeskitse, ét element pr. linje. Brug den til at bygge rammer, stativer og svejste konstruktioner.", + "help.inspect.accessibility": "Farver de flader, et værktøj kan nå fra én retning, og dem, det ikke kan. Brug den til at kontrollere en del for bearbejdning eller støbning fra den side.", + "help.inspect.center_of_mass": "Viser massecentrum for emner, hvert for sig og alle samlet, ved ensartet massefylde. Brug den til at kontrollere balance og hvor en del vil vælte.", + "help.inspect.colored_components": "Giver hver komponent sin egen midlertidige farve uden at ændre udseender. Brug den til at kende delene i en samling fra hinanden med et enkelt blik.", + "help.inspect.compare": "Sammenligner to emner og viser, hvor det ene har materiale, som det andet ikke har, med volumen og afvigelse. Brug den til at kontrollere en ændret eller importeret del mod originalen.", + "help.inspect.continuity": "Kontrollerer, hvor glat flader mødes langs deres kanter: åbninger, knæk eller spring i krumning, og farver overgangene. Brug den til at finde steder, hvor overflader ikke går rent sammen.", + "help.inspect.curvature_comb": "Tegner kamme langs kurver, kanter eller flader, hvis tænder viser, hvor kraftigt de krummer. Brug den til at spotte flade steder, knæk og ujævn krumning.", + "help.inspect.curvature_map": "Farver flader efter, hvor kraftigt de krummer, rødt hvor radius er lille. Brug den til at finde snævre steder og vurdere, hvor glat en overflade er.", + "help.inspect.draft": "Farver flader efter deres slipvinkel i forhold til en udtræksretning og markerer dem med for lidt. Brug den til at kontrollere en del før sprøjtestøbning eller støbning.", + "help.inspect.environment_map": "Viser modellen med en reflekterende, spejlagtig finish. Brug den til at vurdere overfladernes forløb ud fra, hvordan refleksionerne løber hen over dem.", + "help.inspect.interference": "Finder emner, der overlapper, og viser hvert overlap med dets volumen. Brug den til at kontrollere en samling for dele, der støder sammen.", + "help.inspect.isocurve": "Tegner isoparametriske kurver på flader, det gitter overfladen er bygget på. Brug den til at se, hvordan en overflade forløber, og hvor kurverne samler sig.", + "help.inspect.measure": "Måler længder, afstande, vinkler, arealer og radier mellem det, du vælger, eller volumen og masse af emner. Brug den til at kontrollere størrelser og frigang.", + "help.inspect.minimum_radius": "Finder den mindste krumningsradius på kurver, kanter eller flader og hvor den er. Brug den til at kontrollere, at et værktøj eller en dyse kan følge formen.", + "help.inspect.section": "Skærer visningen op langs et plan og skraverer de snittede flader uden at ændre modellen. Brug den til at kigge ind i dele og samlinger.", + "help.inspect.zebra": "Viser sorte og hvide striber reflekteret på modellens flader. Brug den til at vurdere glathed: Striber, der knækker eller brydes ved en kant, viser en dårlig overgang.", + "help.joint.as_built": "Forbinder to dele, hvor de allerede er, uden at flytte nogen af dem, og angiver, hvordan de stadig må bevæge sig. Brug den til dele, du allerede har placeret i deres endelige position.", + "help.joint.cam": "Forbinder en roterende knast med en følger, så følgeren glider eller svinger, når knastens kontur passerer under den. Brug den til knaster, excentre og ventildrev.", + "help.joint.create": "Forbinder to dele ved de lednulpunkter, du vælger, og flytter den anden del på plads; ledtypen bestemmer, hvordan de stadig må bevæge sig. Brug den til at bygge en samling, hvis dele bevæger sig som den virkelige mekanisme.", + "help.joint.drive": "Sætter et leds vinkel eller afstand og bevæger delene med, inden for dets grænser; forbundne led og lukkede sløjfer følger med. Brug den til at kontrollere, at en mekanisme bevæger sig som tilsigtet.", + "help.joint.gear": "Forbinder to roterende led med et tandhjulsforhold, så drejning af det ene drejer det andet. Brug den til ligetandede tandhjul og ringhjul, med forholdet ud fra delecirkelradier eller tandantal.", + "help.joint.limits": "Angiver, hvor langt et led må dreje eller glide, som minimum og maksimum, eller fjerner grænserne. Brug den til at holde et hængsel, et glideled eller et håndtag inden for dets virkelige bevægelsesområde.", + "help.joint.motion_link": "Kobler to led med et forhold og en forskydning, så bevægelse af det ene flytter det andet. Brug den til en fast sammenhæng mellem led, som Tandhjul, Tandstang og tandhjul eller Skrue ikke dækker.", + "help.joint.origin": "Placerer et navngivet lednulpunkt på en del: i midten af en flade eller rund ende, midt på en kant, i et hjørne eller i et punkt. Brug den til at forberede, hvor led sættes fast; et nulpunkt valgt på en flade følger den, når delen ændrer størrelse.", + "help.joint.rack_pinion": "Forbinder et roterende tandhjul med en glidende tandstang, så tandstangen flytter sig lige så langt, som tandhjulets delecirkel ruller. Brug den til tandstangsdrev, styretøj og lineære slæder.", + "help.joint.reach": "Flytter et punkt på en del mod et mål ved at ændre leddene i dets kæde inden for deres grænser. Brug den til at stille en mekanisme ind efter, hvor dens ende skal være, f.eks. spidsen af en robotarm.", + "help.joint.rigid_group": "Låser de valgte dele sammen, som de er, så de bevæger sig som én. Brug den til dele, der hører sammen og ikke skal have nogen bevægelse mellem sig.", + "help.joint.screw": "Forbinder drejning og glidning ved hjælp af en stigning, så hver omdrejning flytter delen så langt. Brug den til ledeskruer, bolte og gevindspindler.", + "help.library.apply": "Anvender et materiale eller udseende fra en lokal biblioteksfil på emner, flader eller komponenter. Brug den til at genbruge materialer, du har gemt fra et andet design.", + "help.library.export": "Gemmer designets materialer og udseender i en biblioteksfil. Brug den til at dele dem eller genbruge dem i andre designs.", + "help.library.inspect": "Kontrollerer en biblioteksfil og viser dens materialer og udseender uden at ændre designet. Brug den til at se, hvad et bibliotek indeholder, før du anvender det.", "help.licences": "Licenser", "help.material.assign": "Fysisk materiale angiver, hvad de valgte emner er lavet af, f.eks. stål, aluminium eller ABS. Brug det, så masse og vægt svarer til den virkelige del.", + "help.material.properties.validate": "Kontrollerer et materialedatakort for konsistente tal og manglende værdier. Brug den, før du støtter dig til dataene for masse eller stivhed.", + "help.mesh.convert": "Genopbygger et meshlegeme, f.eks. en STL, som et redigerbart massivt emne med plane og cylindriske flader. Brug den til at ændre importerede mekaniske dele som et modelleret emne.", "help.mit": "MIT-licens", + "help.motion.study": "Sætter ledværdier i trin langs en tidslinje og afspiller dem som en animation. Brug den til at se en mekanisme bevæge sig gennem en sekvens og kontrollere frigang undervejs.", + "help.motion.trace": "Sporer den bane, et punkt på en del følger, mens en bevægelsesanalyse afspilles, eller et led bevæges, og beholder den som en 3D-skitsekurve, når du bekræfter. Brug den til at se, hvor en mekanisme når hen, eller til at bygge videre på dens bane.", "help.notice": "NOTICE", + "help.occurrence.ground": "Fastgør en del på plads eller frigør den igen, så led flytter de andre dele rundt om den. Brug den til stellet eller basen, som resten af samlingen er bygget på.", "help.occurrence.move": "Flyt komponentforekomst flytter en del i samlingen kun langs de frihedsgrader, som dens led tillader. Vælg en flade på delen, og træk derefter i en tilgængelig pil eller bue.", "help.parameters.change": "Redigér parametre åbner tabellen med navngivne værdier og udtryk, der styrer designets mål. Brug den til at ændre en størrelse ét sted, så alle features, der bruger den, følger med.", + "help.parameters.export": "Gemmer designets parametre som CSV eller JSON. Brug den til at redigere dem i et regneark eller flytte dem til et andet design.", + "help.parameters.graph": "Viser, hvilke parametre og features der bruger hvilke andre, og eventuelle sløjfer eller fejl imellem dem. Brug den til at se, hvad en ændring af en parameter vil påvirke.", + "help.parameters.import": "Læser parametre fra CSV eller JSON ind i designet, enten alle eller ingen. Brug den til at hente værdier fra et regneark eller et andet design.", + "help.parameters.move": "Flytter en brugerparameter før eller efter en anden på listen. Brug den til at holde beslægtede parametre samlet.", + "help.parts.fastener": "Tilføjer en standardskrue, bolt, møtrik, skive, stift eller leje i den størrelse, du vælger. Placeret på et hul sidder den fast i hullet med et led, så du ikke selv skal modellere befæstelseselementer.", + "help.parts.insert": "Indsætter en standarddel i den familie, størrelse og længde, du vælger. Placeret på kanten eller væggen af et hul sidder den fast på hullet med et stivt led.", + "help.parts.library": "Åbner standarddelene: skruer, bolte, møtrikker, skiver, stilleskruer, dyvler og lejer i standardstørrelser. Brug den til at tilføje en standarddel i stedet for selv at modellere den.", + "help.parts.resize": "Ændrer størrelsen eller længden af en indsat standarddel og genopbygger den; dens led holder den på plads. Brug den, når en skrue viser sig at være for lang, eller et hul skal have en anden størrelse.", + "help.plastic.assign_rule": "Tildeler en plastregel til emner, så deres plastfeatures bruger dens slipvinkel og frigang som standard. Brug den til at sætte en sprøjtestøbt del op én gang for alle dens features.", + "help.plastic.boss": "Tilføjer en skruebøsning på en flade: en søjle med valgfrit hul, slipvinkel, afrunding i foden og understøttende ribber. Brug den til skruepunkter i sprøjtestøbte eller printede kabinetter.", + "help.plastic.lip": "Tilføjer en læbe eller en tilhørende not langs kanten af et udhulet emne. Brug den, så to kabinethalvdele passer ind i hinanden og lukker tæt.", + "help.plastic.manage_rules": "Opretter og redigerer plastregler: materiale, vægtykkelse, radier, frigang og slipvinkel. Brug dem til at holde features på en sprøjtestøbt del ensartede.", + "help.plastic.rest": "Tilføjer en understøtning på en flade: en rund eller rektangulær pude, massiv eller hul, med slipvinkel. Brug den som støtte eller afstandsstykke i et sprøjtestøbt kabinet.", + "help.plastic.snap_fit": "Tilføjer en snapkrog på en flade med dens længde, tykkelse og hage. Brug den til at klikke dele sammen uden skruer.", "help.pointcloud.insert": "Indsæt en PLY-, XYZ- eller PTS-scanning som en punktsky ved siden af modellen. Brug den som reference, når du bygger en model ud fra målte data.", + "help.sheet.convert": "Gør en flad plade eller et udhulet emne med ensartet tykkelse til et pladelegeme med bukninger ved de kanter, du vælger. Brug den til at gøre en eksisterende del udfoldelig.", + "help.sheet.export_bend_table": "Gemmer bukketabellen for et pladelegeme som CSV eller JSON: vinkler, radier og bukkepositioner i udfoldningen. Brug den til kantpressen eller værkstedet.", + "help.sheet.export_dxf": "Gemmer udfoldningen som DXF med konturer, bukkelinjer og valgfrie bukkenoter. Brug den til laser-, plasma- eller vandskæring.", + "help.sheet.flange": "Tilføjer en flange: en basisplade ud fra en skitse, en væg langs en åben skitsekontur eller en bukket væg på pladens kanter. Brug den til at opbygge pladedele.", + "help.sheet.flat_pattern": "Opretter udfoldningen af et pladelegeme med dets størrelse, udskæringer og bukkelinjer. Brug den til at kontrollere tilskæringen, før du skærer den ud.", + "help.sheet.fold": "Bukker en plade langs en skitselinje med en vinkel og radius. Brug den til at folde en flad plade, efter at du har skåret features i den.", + "help.sheet.hem": "Ombukker en pladekant over sig selv. Brug den til sikre, stive kanter uden skarpe grater.", + "help.sheet.manage_rules": "Opretter og redigerer pladeregler: tykkelse, bukkeradius, K-faktor, aflastninger og mellemrum. Brug dem til at tilpasse materialet og værktøjerne på dit værksted.", + "help.sheet.path_flange": "Tilføjer en kæde af flanger langs en pladekant efter en åben kæde af skitselinjer. Brug den til profiler som en U- eller Z-form i ét trin.", + "help.sheet.perforate": "Tilføjer et felt af huller over hele den flade plade i et forskudt eller kvadratisk mønster. Brug den til ventilationsåbninger, riste og lettere paneler.", + "help.sheet.refold": "Bukker et udfoldet pladelegeme sammen igen. Brug den, når du har tilføjet features til den flade plade.", + "help.sheet.unfold": "Folder alle bukninger i et pladelegeme ud. Brug den til at tilføje huller eller udskæringer, der skal krydse en bukning, og buk derefter tilbage.", + "help.sketch.arc.center_point": "Tegner en cirkelbue omkring et centrumpunkt fra et startpunkt til et slutpunkt. Brug den, når buens centrum er kendt, f.eks. en radius omkring et hul eller en omdrejningstap.", + "help.sketch.arc.tangent": "Tegner en bue, der starter ved enden af en linje eller bue og fortsætter tangentielt til den. Brug den til at føre en kæde videre med en blød bøjning i stedet for et hjørne.", + "help.sketch.arc.three_point": "Tegner en bue fra et startpunkt til et slutpunkt gennem et tredje punkt. Brug den, når buen skal gå gennem givne punkter i stedet for omkring et kendt centrum.", + "help.sketch.arc3d": "Tegner en nøjagtig cirkelbue i rummet gennem tre punkter, med 3D-skitse slået til. Brug den til bøjninger i baner og styrekurver, der forlader skitseplanet.", + "help.sketch.auto_constrain": "Tilføjer de bindinger, skitsen næsten allerede har, f.eks. vandret, parallel eller sammenfaldende, og målsætter den derefter, til den er fuldt bundet. Brug den til at låse en groft tegnet skitse i ét trin.", + "help.sketch.auto_constrain_from_datum": "Virker som Automatisk binding, men måler positionerne fra et referencepunkt, du vælger, i stedet for nulpunktet. Brug den, når delen er tegnet ud fra ét referencepunkt, f.eks. et hjørne eller et hul.", + "help.sketch.blend_curve": "Forbinder enderne af to kurver med en glat spline, der er tangent til begge, eller også krumningskontinuert, hvis du vælger det. Brug den til at bygge bro over et mellemrum mellem kurver uden knæk.", + "help.sketch.break": "Deler en kurve i stykker, hvor andre kurver krydser den, eller i punkter, du placerer på den. Brug den, når en del af en kurve skal have sin egen binding eller slettes for sig.", + "help.sketch.cancel": "Forlader skitsen og kasserer alt, der er ændret, siden den blev oprettet eller åbnet til redigering. Fortryd bringer ændringerne tilbage.", + "help.sketch.centerline": "Skifter linjer mellem normale linjer og centerlinjer, akser som profiler ikke tager med. Brug en centerlinje som akse til en omdrejning, en spejling eller en symmetribinding.", + "help.sketch.chamfer.distance_angle": "Affaser hjørnet mellem to linjer, angivet ved en afstand langs den første linje og en vinkel. Brug den, når en tegning angiver affasningen som en længde og en vinkel, f.eks. 2 mm × 30°.", + "help.sketch.chamfer.equal_distance": "Affaser hjørnet mellem to linjer med samme afstand tilbage langs begge. Brug den til den sædvanlige 45°-affasning på en kontur.", + "help.sketch.chamfer.two_distance": "Affaser hjørnet mellem to linjer, angivet ved en separat afstand langs hver linje. Brug den til affasninger, der ikke er 45°, angivet som to længder.", + "help.sketch.circle.center": "Tegner en cirkel ud fra dens centrum og diameter. Brug den til huller, bøsninger og andre runde features, hvor centrum er kendt.", + "help.sketch.circle.three_point": "Tegner en cirkel gennem tre punkter. Brug den, når cirklen skal gå gennem givne punkter, f.eks. på eksisterende geometri.", + "help.sketch.circle.three_tangent": "Tegner en cirkel, der berører tre linjer, cirkler eller buer. Brug den til at passe en cirkel ind i rummet mellem tre kurver, f.eks. den indskrevne cirkel i en trekant.", + "help.sketch.circle.two_point": "Tegner en cirkel ud fra de to ender af dens diameter. Brug den, når cirklens kantpunkter er kendt frem for dens centrum.", + "help.sketch.circle.two_tangent": "Tegner en cirkel af en given størrelse, der berører to linjer, cirkler eller buer. Brug den til en afrunding med fast radius mellem to kurver.", + "help.sketch.conic": "Tegner en keglesnitskurve mellem to endepunkter, formet af et toppunkt og en rho-værdi. Brug den til bløde overgange, hvor du vil justere fylden, fra flad til næsten et skarpt hjørne.", + "help.sketch.constrainer": "Anvender den binding, som en eller to valgte kurver næsten allerede har, f.eks. vandret, parallel, tangent eller ens. Brug den til at rydde op i en håndtegnet skitse, én relation ad gangen.", + "help.sketch.constraint.coincident": "Får et punkt til at ligge på et andet punkt, en kurve eller en skitseakse. Brug den til at lukke åbninger i en profil eller til at lægge et punkt på en linje.", + "help.sketch.constraint.collinear": "Får to linjer til at ligge på samme rette linje. Brug den til at stille afbrudte kanter op, f.eks. begge sider af en udskæring.", + "help.sketch.constraint.concentric": "Giver to cirkler eller buer samme centrum. Brug den til ringe, forsænkninger og aksler med skuldre.", + "help.sketch.constraint.curvature": "Får to kurver, der mødes i et endepunkt, til at fortsætte med samme krumning (G2). Brug den, hvor en tangentforbindelse ikke er glat nok, f.eks. på synlige overflader.", + "help.sketch.constraint.equal": "Gør linjer lige lange eller cirkler og buer ens i radius. Brug den, så ét mål styrer flere tilsvarende features.", + "help.sketch.constraint.fix": "Låser punkter eller kurver fast, hvor de er, eller frigør dem igen. Brug den til geometri, der ikke må flytte sig, når du trækker i eller løser skitsen.", + "help.sketch.constraint.horizontal_vertical": "Gør en linje eller linjen mellem to punkter vandret eller lodret, alt efter hvad der er tættest på. Brug den til at rette en kontur ind efter skitsens akser.", + "help.sketch.constraint.midpoint": "Placerer et punkt midt på en linje eller får midtpunkterne af to linjer til at mødes. Brug den til at centrere geometri på en kant.", + "help.sketch.constraint.parallel": "Gør to linjer parallelle. Brug den til sider, der skal forblive parallelle, når andre mål ændres.", + "help.sketch.constraint.perpendicular": "Får to linjer til at mødes i en ret vinkel. Brug den til retvinklede hjørner mellem linjer, der ikke ligger på skitsens akser.", + "help.sketch.constraint.polygon": "Gør en lukket kæde af linjer til en regulær polygon: lige lange sider og alle hjørner på én cirkel. Brug den til at gøre en håndtegnet polygon regulær og holde den sådan.", + "help.sketch.constraint.symmetry": "Får to punkter eller kurver til at spejle hinanden over en linje eller omkring et punkt. Brug den til at holde en profil symmetrisk, når du ændrer den ene side.", + "help.sketch.constraint.tangent": "Får to kurver til at berøre hinanden tangentielt, f.eks. en linje og en bue. Brug den til bløde forbindelser uden knæk.", + "help.sketch.construction": "Skifter kurver mellem normal geometri og konstruktionsgeometri, som profiler ikke tager med. Brug konstruktionskurver som hjælp til layout, referencer og mål.", "help.sketch.construction_line": "Opret en uendelig hjælpelinje gennem to punkter. Brug den til at lægge en skitse ud uden at dele profiler.", "help.sketch.construction_line.angle": "Opret en uendelig hjælpelinje i en vinkel fra skitsens X-akse. Vælg punktet, og skriv vinklen, eller vælg dens retning.", "help.sketch.construction_line.bisector": "Vælg to skærende rette skitselinjer, og vælg derefter en af vinkelhalveringslinjerne. Resultatet følger sine kildelinjer.", @@ -1412,12 +1564,71 @@ "help.sketch.construction_line.tangent_external": "Vælg en cirkel eller cirkelbue, derefter et eksternt punkt og en tangentløsning. Forhåndsvisningerne viser kun berøringspunkter på den tegnede bue.", "help.sketch.construction_line.tangent_two": "Vælg to cirkler eller cirkelbuer, og vælg derefter en fælles tangent i forhåndsvisningen. Berøringspunkterne skal ligge på de tegnede kurver.", "help.sketch.construction_line.vertical": "Opret en uendelig lodret hjælpelinje gennem et punkt. Dens lodrette binding følger senere ændringer.", + "help.sketch.copy": "Kopierer de valgte skitsekurver og -punkter sammen med bindingerne og målene imellem dem. Indsæt dem i denne skitse eller i en anden.", + "help.sketch.create": "Starter en ny 2D-skitse på et plan eller en plan flade på modellen. Enhver ekstrudering, omdrejning og sweep begynder med en skitse.", + "help.sketch.cut": "Kopierer den valgte skitsegeometri og sletter den fra skitsen i ét fortrydelsestrin. Indsæt den et andet sted i denne skitse eller i en anden.", + "help.sketch.dimension": "Tilføjer et styrende mål: en længde, afstand, vinkel, radius eller diameter, alt efter hvad du vælger. Skriv en værdi eller et parameternavn, og skitsen følger det.", + "help.sketch.edit": "Åbner en eksisterende skitse til redigering. De features, der er bygget på den, opdateres, når du afslutter skitsen.", + "help.sketch.ellipse": "Tegner en ellipse ud fra dens centrum, enden af dens storakse og et punkt på dens korte side. Brug den til ovale åbninger, knaster og andre aflange runde former.", "help.sketch.equation_curve": "Ligningskurve tegner en kurve ud fra formler: x(t) og y(t) eller y = f(x) over et interval. Formlerne kan bruge parametre, og kurven følger dem. Brug den til en sinusbølge, en spiral eller evolventen på en tandhjulstand.", + "help.sketch.extend": "Forlænger en linje eller bue op til den nærmeste kurve i vejen eller lader to kurver vokse, til de mødes. Brug den til at lukke åbninger i en profil efter trimning eller redigering.", + "help.sketch.fillet": "Runder hjørnet, hvor to linjer eller buer mødes, af med en bue med en given radius. Flere hjørner, der afrundes sammen, deler én radius.", + "help.sketch.finish": "Forlader skitsen og vender tilbage til modellen. Dens lukkede profiler er derefter klar til Ekstrudering, Omdrejning og andre features.", + "help.sketch.finish_auto_constrain": "Kører Automatisk binding på skitsen og afslutter den derefter. Brug den til at efterlade en skitse fuldt bundet i ét trin.", + "help.sketch.fit_curves_to_section": "Skærer et mesh eller massivt emne med skitseplanet og tilpasser linjer og buer til snittet. Brug den til at genopbygge en scannet eller importeret del som en redigerbar skitse.", + "help.sketch.include_3d": "Henter modelkanter, hjørnepunkter og kurver fra andre skitser ind i denne skitse som linkede referencer og projicerer dem, der ligger uden for dens plan, ned på det. Brug den til at bygge videre på geometri, der ikke ligger i skitseplanet.", + "help.sketch.infinite": "Skifter linjer mellem normale linjer og uendelige hjælpelinjer, der løber hen over hele visningen. Brug dem som layoutreferencer, der aldrig ender for kort.", + "help.sketch.insert_dxf": "Læser kurverne i en DXF-tegning ind i en skitse, skaleret, drejet og placeret, som du angiver. Brug den til at bygge videre på en 2D-tegning fra et andet program, f.eks. en laserskåret kontur.", + "help.sketch.insert_svg": "Læser stierne i en SVG-tegning ind i en skitse, skaleret, drejet og placeret, som du angiver. Brug den til at gøre et logo eller en vektorkontur til kurver, du kan ekstrudere.", "help.sketch.inspect": "Åbner en rapport om den åbne skitse: hvor mange frihedsgrader der er tilbage, og om den er fuldt bundet. Brug den til at finde ud af, hvad der stadig kan bevæge sig, før du afslutter skitsen.", + "help.sketch.intersect": "Tilføjer de kurver eller punkter, hvor emner, flader, kanter eller andre skitser krydser skitseplanet. De forbliver linkede og følger modellen, når den ændres.", + "help.sketch.intersection_curve": "Opretter 3D-kurver, hvor to emner eller flader mødes, linket til dem. Brug dem som baner eller styrekurver, der følger modellen.", + "help.sketch.isoparametric_curve": "Opretter en 3D-kurve langs en flades U- eller V-retning gennem et punkt, du vælger, linket til fladen. Brug den som bane eller delelinje, der følger overfladen.", + "help.sketch.line": "Tegner forbundne linjer punkt for punkt som en åben kæde eller en lukket kontur. Det er det grundlæggende værktøj til de fleste profiler.", + "help.sketch.line.midpoint": "Tegner en linje fra dens midtpunkt ud til den ene ende, og den anden ende spejles. Brug den til linjer centreret om et punkt, f.eks. en symmetrisk kant.", + "help.sketch.line3d": "Tegner forbundne rette linjer mellem punkter i rummet, med 3D-skitse slået til. Brug den til baner og rammer, der forlader skitseplanet.", + "help.sketch.mirror": "Spejler skitsekurver og -punkter over en linje eller akse og tilføjer symmetribindinger. Brug den til at tegne halvdelen af en symmetrisk profil og fuldende den.", + "help.sketch.move": "Flytter eller roterer skitsekurver og -punkter eller en kopi af dem. Brug den til at flytte geometri og bevare dens form og bindinger.", + "help.sketch.offset": "Opretter en kopi af en kæde af kurver i en given afstand, på den ene side eller begge. Brug den til vægge, konturer og frigang; afstanden forbliver et redigerbart mål.", + "help.sketch.paste": "Indsætter skitsegeometri, du har kopieret eller klippet, i den aktive skitse. Placér den i et punkt, eller forskyd den med en afstand.", + "help.sketch.pattern.circular": "Kopierer skitsegeometri omkring et centrumpunkt over en hel cirkel eller en given vinkel. Brug den til boltcirkler, eger og tænder.", + "help.sketch.pattern.rectangular": "Kopierer skitsegeometri i rækker og kolonner med givne afstande. Brug den til gitre af huller, slidser eller ventilationsåbninger.", + "help.sketch.point": "Placerer et skitsepunkt. Brug det som reference til mål, bindinger, hulcentre eller senere features.", + "help.sketch.point3d": "Placerer et punkt i rummet, med 3D-skitse slået til. Brug det som reference til 3D-kurver og baner.", + "help.sketch.polygon.circumscribed": "Tegner en regulær polygon omkring en cirkel med størrelsen målt fra centrum til midten af en side. Brug den, når nøglevidden er kendt, f.eks. til en sekskantmøtrik.", + "help.sketch.polygon.edge": "Tegner en regulær polygon ud fra de to ender af én side. Brug den, når længden og positionen af en side er kendt.", + "help.sketch.polygon.inscribed": "Tegner en regulær polygon inden i en cirkel med størrelsen målt fra centrum til et hjørne. Brug den, når målet over hjørnerne er kendt.", + "help.sketch.project": "Henter modelkanter, flader, hjørnepunkter og kurver fra andre skitser ind i denne skitse, projiceret ned på dens plan. De forbliver linkede og følger modellen, når den ændres.", + "help.sketch.project_to_surface": "Projicerer kurver fra andre skitser langs denne skitses normal ned på en flade som linkede 3D-kurver. Brug den til at lægge et logo, en delelinje eller en bane på en buet overflade.", + "help.sketch.rectangle.center": "Tegner et rektangel ud fra dets centrum og ét hjørne. Brug det til konturer centreret om et punkt, f.eks. nulpunktet.", + "help.sketch.rectangle.rounded": "Tegner et rektangel med alle fire hjørner afrundet med samme radius. Brug det til plader, låg og åbninger med afrundede hjørner.", + "help.sketch.rectangle.three_point": "Tegner et rektangel ud fra én kant og et punkt på den modsatte side. Brug det til rektangler, der er vinklet i forhold til skitsens akser.", + "help.sketch.rectangle.two_point": "Tegner et rektangel ud fra to modstående hjørner. Brug det til konturer af plader, blokke og lommer.", + "help.sketch.scale": "Skalerer skitsekurver og -punkter med en faktor omkring et basispunkt. Brug den til at ændre størrelsen på en del af en skitse uden at tegne den igen.", + "help.sketch.slot.arc_center": "Tegner en buet slids med en given bredde langs en bue, der er angivet ved dens centrum, start og slut. Brug den til justeringsslidser omkring en omdrejningstap.", + "help.sketch.slot.arc_three_point": "Tegner en buet slids med en given bredde langs en bue gennem tre punkter. Brug den, når slidsens bane skal gå gennem givne punkter.", + "help.sketch.slot.center_point": "Tegner en lige slids med en given bredde fra dens centrum til centrum af den ene endebue. Brug den til slidser centreret om et punkt.", + "help.sketch.slot.center_to_center": "Tegner en lige slids med en given bredde mellem centrene af dens to endebuer. Brug den til slidser, der er målsat fra centrum til centrum, f.eks. justeringsslidser til skruer.", + "help.sketch.slot.overall": "Tegner en lige slids med en given bredde fra den ene ydre ende til den anden. Brug den, når slidsens samlede længde er det vigtige.", + "help.sketch.spline.control_point": "Tegner en kubisk spline formet af kontrolpunkter, som den ikke går igennem. Brug den til bløde, flydende kurver, som du former ved at flytte punkterne.", + "help.sketch.spline.control_point_5": "Tegner en spline af grad 5 formet af kontrolpunkter. Brug den, hvor kurver skal mødes med blødere krumning, f.eks. på synlige formgivne overflader.", + "help.sketch.spline.fit_point": "Tegner en glat spline, der går gennem hvert punkt, du placerer, åben eller lukket. Brug den til at følge en form gennem kendte punkter.", + "help.sketch.spline3d": "Tegner en glat kurve i rummet gennem punkter, med 3D-skitse slået til. Brug den til sweep-baner og kabelføringer, der forlader skitseplanet.", + "help.sketch.spun_profile": "Opretter i skitseplanet den kontur, et emne beskriver, når det drejer om en akse. Brug den til at få silhuetten af en drejet del til en omdrejning eller en tegning.", + "help.sketch.text": "Skriver tekst i skitsen i en valgt skrifttype, højde og vinkel; dens ord kan følge parametre. Ekstrudér eller præg den til etiketter og skrift.", + "help.sketch.transform": "Skalerer, drejer og flytter skitsegeometri omkring et centrumpunkt i ét trin. Brug den til at flytte og ændre størrelsen på en markering på én gang.", + "help.sketch.trim": "Fjerner stykket af en kurve mellem de nærmeste skæringer omkring det sted, du klikker. Brug den til at rydde op i overlappende linjer til en lukket profil.", "help.solid.align": "Justér flytter de valgte emner, så et punkt, en flade eller et plan på dem møder et punkt, en flade eller et plan et andet sted. Brug den til at placere en del op mod en anden uden at måle forskydningen.", + "help.solid.boundary_fill": "Danner massive emner ud fra de områder, der er indesluttet af overlappende værktøjsemner, og fylder kun de områder, du vælger. Brug den til at bygge en form ud fra det rum, som flere emner tilsammen afgrænser.", + "help.solid.bounding_solid": "Opretter en boks, der omslutter de valgte emner, med en valgfri margin. Brug den til at se en dels samlede størrelse eller som råemnet, den bearbejdes ud af.", + "help.solid.box": "Opretter en boks ud fra længde, bredde og højde, fra et hjørne eller centreret om et punkt. Brug den til en hurtig blok, en pladsholder eller et emne at skære features i.", "help.solid.chamfer": "Affasning skærer de valgte kanter skråt med den angivne afstand. Brug den til at fjerne skarpe kanter eller lette indføringen af en tap eller skrue.", + "help.solid.coil": "Opretter et skruelinjeformet emne, f.eks. en fjeder, ud fra en diameter, vindinger, højde eller stigning og et rundt eller firkantet tværsnit. Brug den til fjedre, spiraler og skruelinjeformede ribber.", "help.solid.combine": "Kombinér forener emner, skærer dem ud af et målemne eller beholder kun det fælles volumen og kan beholde værktøjsemnerne. Brug den til at samle dele, til at skære en form med en anden eller til at finde ud af, hvor to emner overlapper.", + "help.solid.cone": "Opretter en kegle ud fra en grundcirkel og en højde eller en keglestub, når toppen også har en radius. Brug den til tilspidsninger, tragte, koniske forsænkninger og spidser.", + "help.solid.cylinder": "Opretter en cylinder ud fra en radius eller diameter og en højde, stående på et punkt langs en akse. Brug den til aksler, stifter, bøsninger og runde råemner.", "help.solid.draft": "Slipvinkel hælder de valgte flader med den angivne vinkel, målt fra et neutralt plan. Brug den på støbte eller formsprøjtede dele, så de kan løsnes fra formen.", + "help.solid.emboss": "Hæver skitseprofiler, f.eks. tekst eller et logo, ud af en flade eller sænker dem ned i den. Brug den til skrift, etiketter og mærkning på plane eller runde flader.", "help.solid.extrude": "Ekstrudering giver en skitseprofil dybde ved at tilføje eller fjerne materiale langs en retning. Angiv afstanden for at danne en klods, en forhøjning eller en lomme. Brug Udhulning, hvis en massiv model i stedet skal gøres hul med en bestemt vægtykkelse.", "help.solid.fillet": "Afrunding erstatter de valgte kanter med en glat, cirkulær overgang med den angivne radius. Den kan blødgøre skarpe kanter af hensyn til styrke, komfort eller udseende.", "help.solid.hole": "Hul opretter et rundt hul ved et valgt punkt på en flade. Angiv diameteren, og vælg eventuelt konisk forsænkning, cylindrisk forsænkning eller gevind. Ved gevind vælges en gevindbetegnelse, f.eks. M6.", @@ -1426,20 +1637,60 @@ "help.solid.move": "Flyt flytter eller roterer de valgte emner eller en kopi af dem med den afstand og vinkel, du angiver. Brug den til at flytte et emne eller til at lave en kopi på et andet sted.", "help.solid.offset_face": "Forskyd flade skubber de valgte flader ind eller ud med den afstand, du angiver, og de omkringliggende flader følger med. Brug den til at gøre en væg tykkere, en lomme dybere eller en forhøjning højere uden at redigere skitsen.", "help.solid.pattern.circular": "Cirkulært mønster gentager de valgte objekter omkring en akse med det antal og den samlede vinkel, du angiver. Brug det til boltcirkler, eger, ribber og tænder.", + "help.solid.pattern.path": "Kopierer features eller emner langs en skitsebane efter antal og afstand. Brug den til huller langs en buet kant eller dele langs en skinne.", "help.solid.pattern.rectangular": "Rektangulært mønster gentager de valgte objekter i en eller to retninger med det antal og den afstand, du angiver. Brug det til rækker og gitre af huller, ribber, stifter eller fødder.", + "help.solid.pipe": "Opretter et rundt rør langs en bane af skitsekurver, massivt eller hult med en vægtykkelse. Brug den til rør, ledninger, kabler og skinner.", "help.solid.press_pull": "Tryk/træk tilføjer eller fjerner materiale, når du trækker i en flade, eller afrunder kanter, når du trækker i dem. Brug den til hurtige ændringer direkte på modellen.", "help.solid.remove": "Fjern tager de valgte emner ud af designet fra dette punkt i tidslinjen; tidligere trin beholder dem. Brug den til at fjerne et hjælpeemne, f.eks. et skærende værktøj, du ikke længere har brug for.", "help.solid.replace_face": "Erstat flade flytter de valgte plane flader hen på et plan eller en parallel flade, og emnet vokser eller skrumper, så det mødes med den. Brug den til at få en flade i flugt med en anden del eller et konstruktionsplan.", "help.solid.revolve": "Omdrejning drejer en skitseprofil omkring en valgt akse og danner et omdrejningslegeme. Brug den f.eks. til aksler, knopper og skåle, der er runde omkring en centerlinje.", + "help.solid.rib": "Tilføjer en tynd væg langs en åben skitsekurve, fyldt ud til emnets næste flader. Brug den til at afstive vægge og hjørner, f.eks. i sprøjtestøbte eller printede dele.", "help.solid.scale": "Skalering gør de valgte emner større eller mindre med en faktor, ens i alle retninger eller én pr. akse. Brug den til at ændre størrelsen på en importeret form eller til at tage højde for krympning.", "help.solid.shell": "Udhulning gør et massivt legeme hult med den angivne vægtykkelse. Vælg de flader, der skal åbnes. Indvendig udhulning bevarer de udvendige mål, mens udvendig udhulning bevarer de indvendige mål. Brug den f.eks. til kasser, dæksler og andre tyndvæggede dele.", + "help.solid.sphere": "Opretter en kugle ud fra en radius eller diameter omkring et centrumpunkt. Brug den til kugler, knopper og afrundede ender.", "help.solid.split_body": "Opdel emne skærer et emne i separate emner med et plan, en flade på et andet emne eller en skitselinje. Brug den til at lave dele, der skal printes, bearbejdes eller farves hver for sig.", "help.solid.split_face": "Opdel flade deler de valgte flader langs et plan, en flade på et andet emne eller skitsekurver uden at skære i emnet. Brug den til at give en del af en flade sin egen slipvinkel, farve eller forskydning.", "help.solid.sweep": "Sweep trækker en skitseprofil langs en bane for at danne et massivt legeme. Brug den til rør, skinner, håndtag og alle dele med konstant tværsnit, der følger en kurve.", + "help.solid.thread": "Tilføjer et metrisk ISO-gevind på en cylindrisk flade, enten som kosmetisk gevind eller skåret ind i emnet. Brug det til bolte, gevindhuller og printede skruedele.", + "help.solid.torus": "Opretter en ring ud fra en hovedradius omkring dens akse og en mindre radius til dens runde tværsnit. Brug den til O-ringe, håndtag og runde perler.", + "help.solid.web": "Tilføjer en tynd væg langs hver åben skitsekurve, hver især fyldt ud til emnets næste flader. Brug den til et gitter af understøttende vægge inde i kabinetter og bakker.", + "help.surface.blend": "Opretter en flade, der bygger bro mellem to emners kanter, tangent eller krumningskontinuert til begge. Brug den til at forbinde to overflader glat hen over et mellemrum.", + "help.surface.boundary": "Opretter en flade, der udfylder en sløjfe af to til fire skitsekurver, eller et net af kurver. Brug den til frit formede paneler, som kanterne definerer.", + "help.surface.extend": "Forlænger en overflade ud over dens kanter med en afstand eller op til et plan eller en flade. Brug den, så overflader overlapper, før du trimmer eller syr dem sammen.", + "help.surface.extrude": "Trækker skitsekurver eller profiler en afstand ud til en overflade, kun vægge uden endeflader. Brug den til tynde vægge og til skære- eller deleværktøjer.", + "help.surface.fair": "Udjævner indersiden af frit formede flader, mens deres kanter bliver, hvor de er. Brug den til at udjævne bump og bølgede refleksioner i en overflade.", + "help.surface.loft": "Opretter en overflade, der går gennem to eller flere tværsnit i rækkefølge, uden endeflader. Brug den til skaller og overgange mellem forskellige former.", + "help.surface.match": "Genopbygger en overflades kant, så den mødes med et andet emnes kant med tilsvarende tangent eller krumning. Brug den til at lukke en overgang glat mellem to overflader.", + "help.surface.offset": "Opretter en kopi af flader i en afstand langs deres normaler som et nyt overfladelegeme. Brug den til skind, frigang eller den anden side af en væg.", + "help.surface.patch": "Fylder et skitseområde eller en lukket sløjfe af kanter ud med en flade, plan eller blødt buet. Brug den til at lukke huller i et overfladelegeme.", + "help.surface.reverse_normal": "Vender flader på et overfladelegeme om, så deres yderside vender den anden vej. Brug den, før du giver tykkelse eller syr sammen, når fladerne peger indad.", + "help.surface.revolve": "Drejer skitsekurver eller profiler om en akse til en overflade uden endeflader. Brug den til runde skaller som lampeskærme, skåle og dyser.", + "help.surface.ruled": "Opretter en strimmel af rette linjer ud fra en kæde af kanter langs fladens normal, tangent eller i en vinkel. Brug den til vægge med slipvinkel, udløb og delingsflader.", + "help.surface.stitch": "Syr overfladelegemer sammen langs deres fælles kanter, til et massivt emne, når de lukker sig. Brug den til at gøre et sæt overflader til ét emne.", + "help.surface.sweep": "Fører skitsekurver eller profiler langs en bane til en overflade uden endeflader. Brug den til render, lister og andre tynde former, der følger en bane.", + "help.surface.thicken": "Giver et overfladelegeme en tykkelse og gør det til et massivt emne. Brug den til at lave printbare eller bearbejdelige dele af overflader.", + "help.surface.trim": "Skærer en overflade med et plan, en anden overflade eller skitsekurver og beholder den side, du vælger. Brug den til at skære overflader til form, før du syr dem sammen.", + "help.surface.unstitch": "Adskiller flader på et emne til selvstændige overfladelegemer; et massivt emne falder fra hinanden til overflader. Brug den til at omarbejde enkelte flader på et emne.", + "help.surface.untrim": "Fjerner huller fra en flade eller gendanner dens ydre afgrænsning til overfladens naturlige grænser. Brug den til at få den fulde overflade tilbage efter en trimning.", "help.third_party": "Tredjepartslicenser", "help.timeline.compute_all": "Beregn alle genopbygger alle features i tidslinjen fra starten. Brug den til at opdatere modellen og se, hvilke features der fejler.", "help.timeline.delete": "Sletter de valgte features fra tidslinjen; sletning af en skitse sletter også de features, der er bygget på den. Brug den til at fjerne et trin, som ikke længere er ønsket.", + "help.timeline.dependents": "Viser de features, der ville blive slettet sammen med en feature, og dem, der ville fejle uden den. Brug den, før du sletter eller ændrer en feature.", "help.timeline.play": "Afspil de resterende features én ad gangen med en pause på et halvt sekund efter hver genopbygning. Kør kommandoen igen, eller tryk på Escape for at stoppe; Gå til slutningen hopper med det samme.", + "help.view.apply": "Drejer visningen til en standardretning som forfra, oppefra eller iso eller til en gemt navngiven visning. Brug den til at se på modellen fra en fast vinkel.", + "help.view.delete": "Sletter en gemt navngiven visning. Modellen ændres ikke.", + "help.view.fit": "Zoomer og panorerer, så hele modellen eller markeringen fylder visningen. Brug den, når modellen er røget ud af syne.", + "help.view.grid": "Viser eller skjuler gitteret i visningsområdet. Brug det som hjælp til størrelse og orientering under modellering.", + "help.view.ground_shadow": "Viser eller skjuler modellens skygge på grundplanet. Brug den til lettere at vurdere højde og position.", + "help.view.home": "Drejer visningen tilbage til hjemmeretningen og tilpasser modellen. Brug den til hurtigt at komme tilbage til en kendt visning.", + "help.view.list": "Viser de navngivne visninger, der er gemt i designet. Brug den til at finde en visning, du har gemt tidligere.", + "help.view.navigate": "Drejer, panorerer eller zoomer visningen i trin, f.eks. en kvart omdrejning til venstre. Brug den til at flytte kameraet præcist i stedet for at trække.", + "help.view.navigation_bar": "Viser eller skjuler navigationslinjen nederst i visningsområdet. Skjul den for mere plads, vis den for at nå visningsværktøjerne med musen.", + "help.view.rename": "Giver en gemt navngiven visning et nyt navn. Selve visningen forbliver den samme.", + "help.view.save": "Gemmer den aktuelle kameraposition som en navngiven visning i designet. Brug den til at vende tilbage til samme vinkel senere, f.eks. til skærmbilleder.", + "help.view.spin": "Kører rundt om modellen i visningen kontinuerligt, til næste klik eller tastetryk. Brug den til at præsentere en model fra alle sider.", + "help.view.tool": "Låser et kameraværktøj, rotation, panorering eller zoom, så træk med venstre museknap flytter visningen, til du slår det fra. Brug det på en touchplade eller uden en midterste museknap.", + "help.view.visual_style": "Skifter, hvordan modellen tegnes: skyggelagt med eller uden kanter, trådmodel eller med skjulte kanter. Brug trådmodel til at se ind i modellen, skyggelagt til et rent udseende.", "history.sketch_under_constrained": "Ikke fuldt bundet ({count} frihedsgrader)", "history.sketch_under_constrained_one": "Ikke fuldt bundet ({count} frihedsgrad)", "hole_presets.confirm_delete": "Bekræft sletning", @@ -1772,6 +2023,11 @@ "not a move": "ikke en flytning", "not a number": "ikke et tal", "open a sketch first": "åbn en skitse først", + "parameters.apply_live": "Anvend hver ændring, når du forlader dens felt", + "parameters.apply_live_tip": "Slået fra: Ændringer venter, til du vælger Anvend eller OK, og så genopbygges designet én gang for dem alle. Annullér eller Escape kasserer dem.", + "parameters.held": "{count} ændringer venter på Anvend", + "parameters.held_hint": "Redigér udtryk og enheder. Ændringer venter på Anvend eller OK.", + "parameters.held_one": "1 ændring venter på Anvend", "parts.automatic_name": "automatisk", "parts.dimension.across_flats": "Nøglevidde", "parts.dimension.bore": "Boring", @@ -1962,6 +2218,8 @@ "prefs.origin_plane_names": "Navne på nulpunktets planer", "prefs.origin_plane_names_note": "Navne vist i modeltræet, dialoger og lister. {top} er XY, {front} er XZ og {right} er YZ med Z opad. Kommandoer, scripts og gemte designs beholder XY, XZ og YZ.", "prefs.overall_speed": "Samlet hastighed", + "prefs.parameter_list_rows": "Rækker i parameterlisten", + "prefs.parameter_list_rows_tip": "Pil ned i et tomt værdifelt viser parametrene, favoritter først, op til så mange. Færre holder listen let i et stort design.", "prefs.persistent_ribbon_pressed": "Fremhæv aktive kommandoer på båndet", "prefs.persistent_ribbon_pressed_help": "Fremhæver kommandoer på båndet, mens deres virkning er slået til. Slå fra for at beholde det tidligere neutrale udseende og den tidligere kommandohjælp.", "prefs.perspective_fov": "Synsfelt", @@ -2038,6 +2296,8 @@ "prefs.sketch_tab_always_note": "Fanen Skitse bliver på båndet lige efter Model, mens ingen skitse er åben. Dens tegneværktøjer starter så en skitse på det valgte plan eller den valgte flade eller på det plan, du vælger næste gang, med værktøjet klar i den.", "prefs.sketch_tracking_guides": "Hjælpelinjer til justering efter centre og midtpunkter", "prefs.sketch_tracking_guides_note": "Hold musen over et cirkelcentrum eller midtpunktet på en ret kant, og bevæg den derefter vandret eller lodret for at vise en midlertidig punkteret hjælpelinje.", + "prefs.sketch_tracking_points": "Hjælpelinjer til justering efter endepunkter og hjørner under tegning", + "prefs.sketch_tracking_points_note": "Mens et tegneværktøj venter på et punkt, viser det samme midlertidige punkterede hjælpelinje som ved et centrum eller midtpunkt, når du holder musen over enden af en linje eller bue, et rektangelhjørne eller et frit punkt. Slået fra: kun centre og midtpunkter.", "prefs.sketching": "Skitsering", "prefs.snap_drawing_note": "Mens du tegner eller trækker et skitsepunkt.", "prefs.source": "Kilde", @@ -2065,6 +2325,16 @@ "prefs.theme_follow_off": "Fra", "prefs.theme_follow_stop": "Stop med at følge", "prefs.theme_import_hint": "Klik for at vælge en temafil, slip en her, eller tryk på Ctrl+V (Cmd+V på en Mac), mens et tema er kopieret", + "prefs.theme_option_opacity": "Opacitet", + "prefs.theme_option_opacity_help": "Juster opaciteten af flydende paneler. En nedre grænse for læsbarhed beskytter tekst oven på visningsområdets farver.", + "prefs.theme_option_tab_colours": "Fanefarver", + "prefs.theme_option_tab_colours_help": "Brug dette temas panel- og visningsområdefarver for hver fane på båndet.", + "prefs.theme_option_tab_fade_ms": "Faneovergang", + "prefs.theme_option_tab_fade_ms_help": "Tone panel- og visningsområdefarverne over, når du skifter fane. Nul skifter med det samme.", + "prefs.theme_options_badge": "Muligheder", + "prefs.theme_options_more": "Flere · {count}", + "prefs.theme_options_reset": "Nulstil", + "prefs.theme_options_unavailable": "Ikke tilgængelig på denne enhed", "prefs.theme_paste": "Indsæt tema", "prefs.theme_paste_none": "Udklipsholderen indeholder intet SolidCraft-tema", "prefs.timeline_half_hour": "−30 min", @@ -2086,6 +2356,8 @@ "prefs.under_cursor": "Under markøren", "prefs.units_precision": "Enheder og præcision", "prefs.updates": "Opdateringer", + "prefs.value_boxes_in_view": "Vis værdier ved siden af deres håndtag i visningen", + "prefs.value_boxes_in_view_tip": "En kommandos værdi (Afstand, Radius…) i en boks ved siden af dens håndtag oven på modellen. Slået fra: Du skriver den i kommandovinduet, som overtager tastaturet efter et valg, og intet dækker modellen.", "prefs.view": "Visningens adfærd", "prefs.view_confirm_corner": "OK og Annullér i visningens hjørne", "prefs.view_confirm_corner_note": "Mens en dialog eller en skitse er åben, gør et flueben og et kryds øverst til højre i visningen det samme som dialogens OK og Annullér, eller afslutter og annullerer skitsen. Visningsnavigatoren flyttes ned under dem.", @@ -2097,6 +2369,10 @@ "prefs.viewport_background_theme": "Temaets farveforløb", "prefs.viewport_section_status": "Vis status for snit i visningsområdet", "prefs.viewport_section_status_help": "Vis snitindstillingen i visningsområdet. Escape og den aktive knap på båndet slår den fra. Slå fra for at beholde de tidligere snitkontroller.", + "prefs.viewport_status_details": "Live-detaljer i visningsområdet", + "prefs.viewport_status_details_help": "Redigér aktive analyse- og snitindstillinger i standarddetaljekort. Slå fra for at gendanne skrivebeskyttede signaturforklaringer til analyser og dialogen Snit.", + "prefs.viewport_status_summary": "Fold statuselementer i visningsområdet sammen", + "prefs.viewport_status_summary_help": "Fold tre eller flere statuselementer i visningsområdet sammen i ét oversigtsmærke. Slå fra for at beholde alle elementer i den fulde række.", "prefs.wheel": "Musehjul", "prefs.wheel_trackpad": "Musehjul og pegefelt", "prefs.workspace": "Arbejdsområde", @@ -2975,7 +3251,9 @@ "render.output_ready": "Klar til rendering", "render.output_run_check": "Kør kontrol", "render.output_size_limit": "{method}: op til {dimension} px pr. side og {pixels} pixels. Største passende størrelse: {width} × {height}.", + "render.output_size_method_profile": "For at rendere {width} × {height} skal du vælge {method} med CPU-ressourceprofilen {profile} eller en mindre størrelse.", "render.output_size_ok": "Størrelse OK · {width} × {height} px", + "render.output_size_profile": "For at rendere {width} × {height} skal du vælge CPU-ressourceprofilen {profile} eller en mindre størrelse.", "render.output_swap_orientation": "Byt om på bredde og højde", "render.output_tools_inspector": "Redigér størrelse og format i output-inspektøren.", "render.output_view_image": "Vis billede", @@ -3188,6 +3466,7 @@ "render.size_custom": "Brugerdefineret…", "render.size_full_hd": "Full HD", "render.size_hd": "HD", + "render.size_needs_profile": "{size} · kræver {profile}", "render.size_presets": "Forudindstillinger for størrelse", "render.size_presets_pending": "Kommer med Output-editoren", "render.size_preview": "Forhåndsvisning", @@ -3475,6 +3754,24 @@ "sheet.flange_type_contour": "Kontur", "sheet.flange_type_edge": "Kant", "sheet.no_body": "der er intet pladelegeme: Start et med Flange > Basis, eller omdan en plade til et pladelegeme med Konvertér til plade (vælg dens store plane flade)", + "sheet.personal_rules.confirm_delete": "Bekræft sletning", + "sheet.personal_rules.delete": "Slet", + "sheet.personal_rules.delete_prompt": "Slet denne personlige regel? Regler, der allerede bruges i designs, ændres ikke.", + "sheet.personal_rules.full": "Det personlige bibliotek indeholder allerede 64 regler.", + "sheet.personal_rules.invalid_name": "Angiv et kort navn uden linjeskift eller kontroltegn.", + "sheet.personal_rules.invalid_values": "Udfyld reglens værdier: Hver af dem skal kunne udregnes i dette design, med en positiv tykkelse og en K-faktor fra 0 til 1.", + "sheet.personal_rules.missing": "Den valgte personlige regel er ikke længere tilgængelig.", + "sheet.personal_rules.name": "Navn på regel", + "sheet.personal_rules.name_exists": "Der findes allerede en personlig regel med dette navn. Brug et andet navn.", + "sheet.personal_rules.name_hint": "Et entydigt navn", + "sheet.personal_rules.overwrite": "Gem ændringer i den valgte personlige regel", + "sheet.personal_rules.rename": "Omdøb", + "sheet.personal_rules.rule": "Personlig regel", + "sheet.personal_rules.save": "Gem som ny", + "sheet.personal_rules.scope": "Gemt i din profil og tilgængelig i alle designs.", + "sheet.personal_rules.select": "Vælg en regel", + "sheet.personal_rules.title": "Personlige regler", + "sheet.personal_rules.unsupported": "Denne regel har værdier, som denne version ikke kan bruge.", "shortcut.json_error": "parametre skal være JSON: {error}", "shortcut.no_command": "ingen kommando `{id}`; teksten før JSON skal være et kommando-id", "shortcut.pin": "Fastgør øverst i S-boksen", @@ -3511,6 +3808,7 @@ "sketch_text.align_right": "Højre", "sketch_text.bold": "Fed", "sketch_text.builtin_font": "{family} (indbygget)", + "sketch_text.formula": "Start med = for at bygge ordene ud fra parametre: ='W' + number(Width)", "sketch_text.italic": "Kursiv", "sketch_text.loading_fonts": "Søger efter installerede skrifttyper…", "sketch_text.on_path": "Teksten følger sin bane: Kurven bestemmer placeringen", @@ -3651,6 +3949,8 @@ "ui.3d_mouse_section": "3D-mus", "ui.3d_mouse_show_in_view": "Vis 3D-musen i 3D-visningen", "ui.3d_mouse_show_in_view_tip": "Et billede af din 3D-mus i nederste venstre hjørne, der bevæger sig, når du bevæger den.", + "ui.3d_mouse_sketch_orbit": "Drej skitser med 3D-musen", + "ui.3d_mouse_sketch_orbit_tip": "3D-musen drejer også visningen, mens en skitse er åben og set lige forfra, ligesom musen kan. Slået fra: Den panorerer og zoomer skitsen, indtil du vipper visningen.", "ui.3d_mouse_skip": "Min enhed kan ikke det", "ui.3d_mouse_skip_tip": "Springer bevægelsen over begge veje, og kalibreringen udelader den.", "ui.3d_mouse_speed": "3D-musens hastighed", @@ -3719,6 +4019,7 @@ "ui.agent_chat_auth_terminal": "Kør dette i en terminal, og tryk derefter på Forbind igen:", "ui.agent_chat_auth_title": "Log ind hos agenten", "ui.agent_chat_cancel": "Annullér", + "ui.agent_chat_cannot_start": "Installeret, men den starter ikke: {error}", "ui.agent_chat_changes": "Ændringer: {n}, ét fortrydelsestrin", "ui.agent_chat_changes_n": "{n} ændringer", "ui.agent_chat_changes_of": "{n}/{limit} ændringer", @@ -3811,9 +4112,12 @@ "ui.agent_chat_queued_stopped": "I kø (stoppet, ikke sendt)", "ui.agent_chat_refresh": "Opdatér", "ui.agent_chat_refused": "Ikke tilladt for agenten: {reason}", + "ui.agent_chat_reinstall": "{agent} er installeret ({program}), men den startede ikke, da SolidCraft kontrollerede den: {error}\nInstallationen kan være ufuldstændig eller lavet til en anden type computer. Installér den igen med `{install}`, kontrollér i en terminal, at den udskriver sin version, og tryk derefter på Opdatér.", "ui.agent_chat_remove": "Fjern fra køen", "ui.agent_chat_remove_file": "Fjern fra denne besked", "ui.agent_chat_remove_imported": "Fjern fra denne besked; emnet bliver i designet (Fortryd fjerner det)", + "ui.agent_chat_reply_cut_off": "Svaret nåede sin længdegrænse, før det var færdigt, så det kan være ufuldstændigt. Bed agenten om at fortsætte eller om et kortere svar.", + "ui.agent_chat_reply_declined": "Modellen afviste at svare på denne besked.", "ui.agent_chat_retry": "Prøv igen", "ui.agent_chat_right": "Højre", "ui.agent_chat_save": "Gem", @@ -3872,12 +4176,17 @@ "ui.ai_copy_suffix": "kopi", "ui.ai_custom_url": "Brugerdefineret URL", "ui.ai_default": "Standard", + "ui.ai_edit": "Redigér", + "ui.ai_edit_agent_title": "Redigér agent", + "ui.ai_edit_gone": "Denne agent er fjernet, så der er intet at gemme.", + "ui.ai_edit_tip": "Ændr denne agents navn, kommando eller endepunkt. En gemt nøgle forbliver skjult.", "ui.ai_http_busy": "endepunktet er optaget eller har nået sin grænse; prøv igen senere", "ui.ai_http_key": "endepunktet afviste nøglen: Kontrollér den i Indstillinger > AI", "ui.ai_http_not_found": "endepunktet eller modellen findes ikke: Kontrollér basis-URL og modelnavn", "ui.ai_http_too_large": "samtalen er for stor til dette endpoint; start en ny chat", "ui.ai_key": "API-nøgle", "ui.ai_key_hint": "valgfri; gemmes i systemets nøglering", + "ui.ai_key_keep_hint": "lad feltet stå tomt for at beholde den gemte nøgle", "ui.ai_key_needs_https": "En nøgle sendes kun via HTTPS eller til denne computer.", "ui.ai_key_note": "Gemmes i systemets nøglering, aldrig i SolidCrafts indstillinger. Lad feltet være tomt ved en lokal server.", "ui.ai_keychain_error": "Systemets nøglering afviste anmodningen: {error}", @@ -3909,6 +4218,7 @@ "ui.ai_request_timeout": "Tidsgrænse for anmodning", "ui.ai_request_timeout_hint": "Hvor lang tid en modelanmodning til dette endepunkt må tage, fra 30 s til en time. Hæv den for en langsom lokal model.", "ui.ai_reset": "Nulstil", + "ui.ai_save": "Gem", "ui.ai_settings": "AI-indstillinger…", "ui.ai_show_features": "Vis AI-funktioner", "ui.ai_show_features_note": "Agentchatten: Et panel, hvor en kodeagent, du allerede bruger, eller en modeltjeneste, du vælger, arbejder på det åbne design. Slået fra: Ingen AI-knap, genvej, menupunkt eller panel, og der søges ikke efter eller startes noget.", @@ -4082,6 +4392,40 @@ "ui.design_properties.project": "Projekt", "ui.design_properties.revision": "Revision", "ui.design_saved": "Gemte {path}", + "ui.details_access_caption": "Flader viser, om et værktøj kan nå dem fra den valgte retning.", + "ui.details_angle": "Vinkel", + "ui.details_continuity_caption": "Redigér den valgte overgang. Andre overgange beholder deres egne indstillinger.", + "ui.details_continuity_stale": "Tidligere geometri: Kør analysen igen for at redigere eller indramme denne overgang.", + "ui.details_copy_list": "Kopiér liste", + "ui.details_curvature": "Krumningstolerance", + "ui.details_curvature_caption": "Flader farves efter deres lokale krumningsradius.", + "ui.details_direction": "Værktøjsretning", + "ui.details_draft_caption": "Flader farves efter deres vinkel til udtræksretningen.", + "ui.details_edit_seam": "Redigér overgang", + "ui.details_fixture": "Komponenteksempel — kun eksempeldata", + "ui.details_fixture_caption": "Komponenteksempel med illustrative værdier; dette er ikke et resultat fra modellen.", + "ui.details_fixture_problem": "Eksempel på fund med et problem", + "ui.details_fixture_readout": "Eksempel på målt værdi", + "ui.details_flip": "Vend", + "ui.details_gap": "Åbning", + "ui.details_hatch": "Skraver snitflader", + "ui.details_jump": "Krumningsspring", + "ui.details_key": "Forklaring", + "ui.details_offset": "Forskydning", + "ui.details_plane": "Plan", + "ui.details_pull": "Udtræksretning", + "ui.details_radius": "Radius", + "ui.details_readout": "Aflæsning", + "ui.details_samples": "Prøvepunkter", + "ui.details_seam": "{body} · Kant {edge}", + "ui.details_settings": "Indstillinger", + "ui.details_sharp_gap": "Skarp eller åbning", + "ui.details_show_all": "Vis alle {count}", + "ui.details_smooth": "Glat", + "ui.details_stripes": "Striber", + "ui.details_target": "Indram geometri", + "ui.details_worst_seams": "Dårligste overgange", + "ui.details_zebra_caption": "Reflekterede striber viser, hvor glat flader går sammen.", "ui.diameter": "Diameter", "ui.dimension": "Målsætning", "ui.direct_interaction": "Direkte interaktion", @@ -4142,6 +4486,7 @@ "ui.equation_y_of_x": "y(x)", "ui.equivalent_radius": "Ækvivalent radius", "ui.expand": "Fold ud", + "ui.explode_needs_two_components": "Eksploderet visning trækker komponenter fra hinanden, så designet skal have to eller flere af dem (Ny komponent, Indsæt design eller Opret komponenter fra emner)", "ui.exportDxf": "Eksportér DXF…", "ui.export_ellipsis_keymap": "Eksportér…", "ui.export_keymap_tip": "Gem tastaturgenveje, favoritter i S-menuen og værktøjslinjen i en fil", @@ -4536,6 +4881,7 @@ "ui.selectOther": "Vælg andet…", "ui.selectTangentChain": "Vælg tangentkæde", "ui.select_all": "Vælg alle", + "ui.select_components_by_face": "Vælg komponenter (en flade vælger dens komponent)", "ui.selection": "Markering", "ui.selection_modifiers": "Taster ved markering", "ui.selection_shift_add_only": "Shift+klik tilføjer uden at fjerne", @@ -4695,6 +5041,10 @@ "ui.viewport_status_section_plane": "Aktuelt plan", "ui.viewport_status_section_tilted": "vippet · {offset}", "ui.viewport_status_stripes": "{count} striber", + "ui.viewport_status_summary_clear": "Slå alt fra", + "ui.viewport_status_summary_heading": "I denne visning", + "ui.viewport_status_summary_label": "{name} og {count} flere elementer", + "ui.viewport_status_summary_more": "+{count}", "ui.viewport_theme_follow": "Følg tema", "ui.visual_shaded_hidden_edges": "Skyggelagt med skjulte kanter", "ui.visual_shaded_with_edges": "Skyggelagt med kanter", diff --git a/assets/locales/de.json b/assets/locales/de.json index 731e1dd5f7..683759b9e6 100644 --- a/assets/locales/de.json +++ b/assets/locales/de.json @@ -822,6 +822,7 @@ "cmd.appearance_pick_up": "Aussehen aufnehmen", "cmd.appearance_replace": "Erscheinungsbild ersetzen", "cmd.assembly_bom": "Stückliste", + "cmd.assembly_mass_properties": "Masseeigenschaften", "cmd.body.print_face": "Auflagefläche", "cmd.body_rename": "Körper umbenennen", "cmd.browser_collapse_all": "Alles einklappen", @@ -1247,6 +1248,7 @@ "cmd.view.render_browser": "Render-Browser", "cmd.view.render_dock": "Render-Panel verschieben", "cmd.view.render_focus": "Render-Fokusansicht", + "cmd.view.status_details": "Statusdetails des Ansichtsfensters", "cmd.view.status_items": "Ansichtsstatus-Elemente", "cmd.view_ambient_occlusion": "Umgebungsverdeckung", "cmd.view_angle_readout": "Ansichtswinkel", @@ -1285,8 +1287,14 @@ "color.hex_hint": "#rrggbb", "color.hue": "Farbton", "color.linear": "Farbwerte im linearen Raum 0-1 anzeigen", + "color.recent": "Zuletzt verwendete Farben", + "color.recent_help": "Farben, die Sie in einem beliebigen Farbfeld wählen, erscheinen hier", + "color.saturation": "Sättigung (Prozent)", "color.saturation_value": "Sättigung und Wert", "color.selected": "Ausgewählte Farbe", + "color.step_down": "Schritt nach unten", + "color.step_up": "Schritt nach oben", + "color.value": "Hellwert (Prozent)", "config.column_filter_empty": "Keine passenden Spalten", "contact.components_count": "{count} Komponenten", "contact.enabled_label": "aktiviert", @@ -1333,6 +1341,7 @@ "dialog.dimension_name": "Bemaßung: {name}", "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.", "dialog.select": "↖ Auswählen", "dialog.selected_count": "↖ {count} ausgewählt", "dialog.selection_guidance": "Wählen Sie eine Eingabe aus, um sie zu ändern. Klicken Sie auf ausgewählte Objekte, um sie zu entfernen; Strg/Cmd-Klick schaltet sie ebenfalls um. Prüfen Sie die Vorschau und drücken Sie dann OK.", @@ -1390,21 +1399,168 @@ "from the rule": "aus der Regel", "help.apache": "Apache-Lizenz 2.0", "help.appearance.assign": "Weist den ausgewählten Körpern, Flächen oder Komponenten eine Farbe oder ein Erscheinungsbild zu, ohne ihre Masse zu verändern. So können Sie Bauteile optisch unterscheiden oder das Aussehen einer Oberfläche vorab prüfen.", + "help.assembly.bom": "Listet die Teile des Designs oder einer Komponente mit ihrer Anzahl nach Teilenummer auf und kann die Liste als CSV oder JSON speichern. Als Stückliste für Bestellung und Montage.", "help.attributions": "Quellenangaben (ATTRIBUTION.md)", "help.build": "Version {version} · Commit {commit} · erstellt am {date}", + "help.canvas.decal": "Bringt ein Bild wie einen Aufkleber auf eine ebene Fläche des Modells. Damit zeigen Sie ein Etikett, ein Logo oder einen Aufdruck auf einem Teil.", + "help.canvas.edit": "Ändert eine Zeichenfläche: Lage, Größe, Winkel, Deckkraft, Spiegelung, Sichtbarkeit oder Namen. Damit richten Sie ein Referenzbild an der Skizze aus, die Sie darüber nachzeichnen.", + "help.canvas.insert": "Legt ein PNG- oder JPEG-Bild als durchscheinende Referenz auf eine Ebene oder Fläche. Damit zeichnen Sie ein Foto oder eine Zeichnung in einer Skizze nach, mit dem Bild auf eine bekannte Größe skaliert.", + "help.component.break_link": "Löst eine verknüpfte Komponente von ihrer Konstruktionsdatei und behält ihre Körper unverändert. Damit folgt das Teil Änderungen an der Originaldatei nicht mehr.", + "help.component.capture_position": "Übernimmt die Lage von Teilen, die Sie von Hand verschoben haben, als ihre neue Position. Für Teile, die von keinem Gelenk gehalten und von Hand an ihren Platz gezogen wurden.", + "help.component.create": "Fügt der Konstruktion eine neue, leere Komponente hinzu, in der aktiven Komponente, sofern Sie kein anderes übergeordnetes Element wählen. Damit bekommt ein neues Teil einer Baugruppe einen eigenen Platz für Körper und Skizzen.", "help.component.finish_edit": "Speichert das an Ort und Stelle bearbeitete Teil, kehrt zu seiner Baugruppe zurück und aktualisiert die Baugruppe daraus. Verwenden Sie es, wenn Ihre Änderungen am verknüpften Teil fertig sind.", + "help.component.from_bodies": "Macht aus jedem gewählten Körper eine eigene Komponente, benannt nach dem Körper. Für Konstruktionen aus losen Körpern, die zu einer Baugruppe mit verbindbaren, beweglichen Teilen werden sollen.", + "help.component.insert_design": "Fügt eine andere gespeicherte Konstruktion als Komponente ein, die mit ihrer Datei verknüpft ist; ihre Volumenkörper liegen so wie dort. Damit verwenden Sie ein Teil in einer Baugruppe wieder und übernehmen spätere Änderungen mit „Verknüpfungen aktualisieren“.", + "help.component.properties": "Zeigt Volumen, Masse, Schwerpunkt und Trägheitsmomente einer Komponente anhand der Materialien ihrer Körper. Damit prüfen Sie Gewicht und Balance einer Baugruppe.", + "help.component.revert_position": "Setzt von Hand verschobene Teile an ihre vorige Stelle zurück und verwirft nicht erfasste Verschiebungen. Damit machen Sie Probebewegungen in einer Baugruppe rückgängig.", + "help.component.update_links": "Liest die verknüpften Konstruktionsdateien neu ein und ersetzt Körper, wo sie sich geändert haben. Damit übernehmen Sie Änderungen an einem Teil, das diese Baugruppe verwendet.", + "help.config.configure": "Fügt Parameter, Elemente, Materialien oder Darstellungen als Spalten der Konfigurationstabelle hinzu. Damit bauen Sie Varianten eines Designs, etwa Baugrößen eines Teils.", + "help.config.preview": "Prüft, ob sich eine Zeile der Konfigurationstabelle aufbauen lässt, ohne das Design oder seinen Verlauf zu ändern. Vor dem Wechsel zu einer Variante, bei der Sie unsicher sind.", + "help.config.rules": "Zeigt die Regeln der Konfigurationstabelle, ihre Zeilen und die aktive Zeile. Damit sehen Sie, wie die Varianten eines Designs aufgebaut sind.", + "help.config.table": "Zeigt die Konfigurationstabelle: eine Zeile je Variante, eine Spalte je Parameter oder Element, und ob das Design noch der aktiven Zeile entspricht. Damit überblicken Sie die Varianten eines Designs.", "help.connect_note": "Starten Sie jeden Assistenten neu, damit er den SolidCraft-Server lädt. Jede Datei wurde vor der Änderung als .bak gesichert.", "help.connect_title": "KI-Assistenten verbinden", + "help.construct.axis.edge": "Erzeugt eine Konstruktionsachse entlang einer geraden Kante oder Skizzenlinie. Als Achse für Drehung, Kreismuster oder Verbindung.", + "help.construct.axis.normal_to_face": "Erzeugt eine Konstruktionsachse durch einen Punkt, senkrecht zu einer Fläche. Als Achse, die gerade aus einer Fläche heraussteht.", + "help.construct.axis.through_cylinder": "Erzeugt eine Konstruktionsachse durch die Mitte einer Zylinder-, Kegel- oder Torusfläche oder einer Kreiskante. Damit drehen oder mustern Sie Elemente um ein rundes Teil.", + "help.construct.axis.two_planes": "Erzeugt eine Konstruktionsachse, wo sich zwei Ebenen oder ebene Flächen schneiden. Wenn die benötigte Achse die Schnittlinie zweier Flächen ist.", + "help.construct.axis.two_points": "Erzeugt eine Konstruktionsachse durch zwei Punkte, Eckpunkte oder Skizzenpunkte. Für eine Achse zwischen zwei bekannten Lagen.", + "help.construct.plane.along_path": "Erzeugt eine Konstruktionsebene senkrecht zu einer Kante oder Kurve, an einer Stelle entlang dieser. Damit skizzieren Sie ein Profil für eine Austragung oder einen Querschnitt eines Pfads.", + "help.construct.plane.at_angle": "Erzeugt eine Konstruktionsebene, die gegenüber einer Ebene oder Fläche um eine Achse oder Kante gedreht ist. Für schräge Skizzen, etwa eines Winkelhalters.", + "help.construct.plane.midplane": "Erzeugt eine Konstruktionsebene mittig zwischen zwei Ebenen oder ebenen Flächen oder als Winkelhalbierende, wo sie schräg zueinander stehen. Als Symmetrieebene zum Spiegeln.", + "help.construct.plane.offset": "Erzeugt eine Konstruktionsebene parallel zu einer Ebene oder ebenen Fläche, in einem Abstand oder durch einen Punkt. Damit skizzieren Sie in fester Höhe über einer Fläche.", + "help.construct.plane.perpendicular": "Erzeugt eine Konstruktionsebene, die eine Linie oder Kante enthält und senkrecht auf einer Ebene oder Fläche steht. Damit skizzieren Sie aufrecht entlang einer Kante am Modell.", + "help.construct.plane.tangent": "Erzeugt eine Konstruktionsebene, die einen Zylinder, Kegel, eine Kugel oder einen Torus in einem Punkt berührt. Damit skizzieren Sie seitlich an einem runden Teil.", + "help.construct.plane.three_points": "Erzeugt eine Konstruktionsebene durch drei Punkte. Für eine Ebene, die durch Lagen am Modell bestimmt ist, etwa drei Ecken.", + "help.construct.plane.two_edges": "Erzeugt eine Konstruktionsebene durch zwei gerade Kanten oder Linien, die in einer Ebene liegen. Für eine Ebene quer durch ein Teil, etwa durch zwei gegenüberliegende Kanten.", + "help.construct.point.along_path": "Erzeugt einen Konstruktionspunkt an einer Stelle entlang einer Kante oder Kurve. Als Bezug auf halbem oder beliebigem Weg entlang eines Pfads.", + "help.construct.point.center": "Erzeugt einen Konstruktionspunkt im Mittelpunkt einer Kreiskante, eines Skizzenkreises oder -bogens, einer Kugel oder eines Torus. Als Bezug für Bohrungen, Achsen und Verbindungen.", + "help.construct.point.edge_plane": "Erzeugt einen Konstruktionspunkt, wo eine Kante oder Linie eine Ebene oder ebene Fläche durchstößt. Damit markieren Sie, wo ein Pfad eine Ebene kreuzt.", + "help.construct.point.three_planes": "Erzeugt einen Konstruktionspunkt, wo sich drei Ebenen oder ebene Flächen treffen. Als Eckbezug, auch wo das Modell keinen Eckpunkt hat.", + "help.construct.point.two_edges": "Erzeugt einen Konstruktionspunkt, wo sich zwei Kanten oder Linien treffen oder am nächsten kommen. Als Bezug an einer virtuellen Ecke.", + "help.construct.point.vertex": "Erzeugt einen Konstruktionspunkt auf einem Eckpunkt oder Skizzenpunkt. Als Bezug, der bleibt, wenn Skizze oder Körper darum ausgeblendet sind.", + "help.contact.check": "Listet die Teilepaare in Kontaktsätzen auf, die sich in der aktuellen Stellung durchdringen. Damit finden Sie Kollisionen, bevor Sie Gelenke antreiben oder eine Bewegungsuntersuchung abspielen.", + "help.contact.create": "Fasst zwei oder mehr Teile zu einem Kontaktsatz zusammen, dessen Körper sich beim Bewegen der Gelenke nicht durchdringen. Für Teile, die beim Berühren anhalten sollen, etwa ein Hebel an seinem Anschlag.", + "help.contact.disable_all": "Schaltet den Kontakt aus, sodass angetriebene Gelenke und Bewegungsuntersuchungen nicht mehr anhalten, wo sich Teile berühren. Damit bewegen Sie Teile wieder frei oder halten eine große Baugruppe flüssig.", + "help.contact.edit": "Ändert einen Kontaktsatz: die enthaltenen Teile, den Namen oder ob er unterdrückt ist; der Satz lässt sich auch löschen. Damit passen Sie an, welche Teile sich nicht durchdringen dürfen.", + "help.contact.enable_all": "Nimmt alle Teile der Baugruppe in einen gemeinsamen Kontaktsatz, sodass sich beim Bewegen der Gelenke keine zwei Teile durchdringen. Für eine schnelle Prüfung des ganzen Mechanismus.", + "help.contact.enable_sets": "Schaltet den Kontakt für Ihre definierten Kontaktsätze ein, sodass angetriebene Gelenke und Bewegungsuntersuchungen anhalten, wo sich deren Teile durchdringen würden. Damit holen Sie Ihre Sätze zurück, nachdem der Kontakt aus war.", + "help.contact.list": "Listet die Kontaktsätze der Konstruktion auf, die Teile in jedem Satz und ob der Kontakt eingeschaltet ist. Damit prüfen Sie die Kontakteinstellung, bevor Sie die Baugruppe antreiben.", + "help.document.inspect": "Zeigt eine Übersicht des Designs: Komponenten, Körper, Skizzen, Elemente und Parameter. Damit finden Sie sich in einem Design zurecht, das Sie nicht selbst gebaut haben.", + "help.document.units": "Ändert die Einheit, in der die Konstruktion Längen anzeigt, etwa mm oder Zoll. Die Geometrie behält ihre Größe; nur Anzeige und Zahlen ohne Einheit folgen.", + "help.edit.checkpoint": "Merkt sich das Design, die aktive Skizze und den Rückgängig-Verlauf im jetzigen Zustand. Vor einem riskanten Versuch, von dem Sie vielleicht zurück wollen.", + "help.edit.restore_checkpoint": "Kehrt zu einem Prüfpunkt zurück, samt Rückgängig-Verlauf. Damit verwerfen Sie alles seit dem Prüfpunkt in einem Schritt.", + "help.explode.create": "Erstellt eine Explosionsdarstellung, die die Teile vom Mittelpunkt der Baugruppe wegschiebt oder um die von Ihnen festgelegten Wege verschiebt. Damit zeigen Sie, wie eine Baugruppe zusammengesetzt wird, für Anleitungen oder Präsentationen.", "help.face.delete": "Entfernt die ausgewählten Flächen. Die angrenzenden Flächen schließen dabei die entstandene Lücke, oder sie bleibt als offene Fläche bestehen. So können Sie beispielsweise eine Rundung, eine Bohrung oder einen störenden Ansatz entfernen.", + "help.file.insert_mesh": "Fügt eine 3MF-, STL-, OBJ-, PLY- oder glTF-Datei als Netzkörper in einer neuen Komponente in die Konstruktion ein. Damit stellen Sie ein heruntergeladenes oder gescanntes Modell als Referenz neben Ihre Konstruktion.", + "help.file.insert_step": "Fügt eine STEP-, IGES-, FreeCAD- oder Rhino-Datei als Volumenkörper in einer neuen Komponente in die Konstruktion ein; die Teile einer Baugruppe werden zu verschachtelten Komponenten. Damit übernehmen Sie Modelle von Herstellern oder aus anderen CAD-Programmen.", + "help.file.save_mesh": "Speichert Körper als STL-, 3MF- oder OBJ-Netz, mit der gewählten Glätte gekrümmter Flächen. Damit bereiten Sie Teile für den 3D-Druck oder für Programme vor, die Netze lesen.", + "help.file.send_to_slicer": "Speichert die gewählten Körper als 3MF-Datei und öffnet sie im gewählten Slicer. Damit kommen Sie in einem Schritt vom Modell zum Druck.", "help.font_details": "Die Oberfläche verwendet die integrierten Schriftarten von egui (Ubuntu Light und Hack unter der Ubuntu Font Licence und der MIT/Bitstream-Vera-Lizenz; Noto Emoji und die Emoji-Symbolschrift unter OFL und MIT). Skizzentext verwendet Liberation Sans (SIL OFL 1.1). Das optionale Schriftpaket craft-fonts von The Crafting Apps (BIZ UDPGothic, Shippori Mincho, BIZ UDMincho, Noto Sans CJK SC, Noto Sans Arabic; SIL OFL 1.1) ist nicht in SolidCraft-Builds enthalten.", "help.fonts": "Schriftarten", + "help.frame.member": "Setzt ein Profil, etwa ein Rohr oder ein Aluprofil, entlang der Linien einer Rahmenskizze, ein Stab je Linie. Für Rahmen, Gestelle und Schweißkonstruktionen.", + "help.inspect.accessibility": "Färbt die Flächen, die ein Werkzeug aus einer Richtung erreicht, und die, die es nicht erreicht. Damit prüfen Sie ein Teil auf Fräsen oder Formen von dieser Seite.", + "help.inspect.center_of_mass": "Zeigt den Schwerpunkt von Körpern, einzeln und zusammen, bei gleichmäßiger Dichte. Damit prüfen Sie die Balance und wohin ein Teil kippt.", + "help.inspect.colored_components": "Gibt jeder Komponente vorübergehend eine eigene Farbe, ohne die Darstellungen zu ändern. Damit unterscheiden Sie die Teile einer Baugruppe auf einen Blick.", + "help.inspect.compare": "Vergleicht zwei Körper und zeigt, wo einer Material hat, das dem anderen fehlt, mit Volumen und Abweichung. Damit prüfen Sie ein geändertes oder importiertes Teil gegen das Original.", + "help.inspect.continuity": "Prüft, wie glatt Flächen an ihren Kanten ineinander übergehen: Lücken, Knicke oder Krümmungssprünge, und färbt die Nähte. Damit finden Sie Stellen, an denen Flächen nicht sauber anschließen.", + "help.inspect.curvature_comb": "Zeichnet Kämme an Kurven, Kanten oder Flächen, deren Zinken zeigen, wie stark sie gekrümmt sind. Damit erkennen Sie flache Stellen, Knicke und ungleichmäßige Krümmung.", + "help.inspect.curvature_map": "Färbt Flächen danach, wie stark sie gekrümmt sind, rot bei kleinem Radius. Damit finden Sie enge Stellen und beurteilen, wie glatt eine Fläche ist.", + "help.inspect.draft": "Färbt Flächen nach ihrem Formschrägenwinkel zur Entformungsrichtung und markiert die mit zu wenig. Damit prüfen Sie ein Teil vor dem Spritzgießen oder Gießen.", + "help.inspect.environment_map": "Zeigt das Modell mit spiegelnder Oberfläche. Damit beurteilen Sie den Verlauf von Flächen an der Art, wie Spiegelungen darüber laufen.", + "help.inspect.interference": "Findet Körper, die sich überschneiden, und zeigt jede Überschneidung mit ihrem Volumen. Damit prüfen Sie eine Baugruppe auf kollidierende Teile.", + "help.inspect.isocurve": "Zeichnet die isoparametrischen Kurven von Flächen, das Raster, auf dem die Fläche aufbaut. Damit sehen Sie, wie eine Fläche verläuft und wo sie sich staucht.", + "help.inspect.measure": "Misst Längen, Abstände, Winkel, Flächen und Radien zwischen den gewählten Elementen oder Volumen und Masse von Körpern. Damit prüfen Sie Maße und Abstände.", + "help.inspect.minimum_radius": "Findet den kleinsten Krümmungsradius von Kurven, Kanten oder Flächen und seine Lage. Damit prüfen Sie, ob ein Werkzeug oder eine Düse der Form folgen kann.", + "help.inspect.section": "Schneidet die Ansicht entlang einer Ebene auf und schraffiert die Schnittflächen, ohne das Modell zu ändern. Damit schauen Sie in Teile und Baugruppen hinein.", + "help.inspect.zebra": "Zeigt schwarze und weiße Streifen, die sich auf den Flächen des Modells spiegeln. Damit beurteilen Sie Glätte: Streifen, die an einer Kante brechen oder knicken, zeigen einen schlechten Übergang.", + "help.joint.as_built": "Verbindet zwei Teile dort, wo sie bereits liegen, ohne eines davon zu verschieben, und legt fest, wie sie sich noch bewegen dürfen. Für Teile, die Sie schon an ihre endgültige Stelle gesetzt haben.", + "help.joint.cam": "Koppelt eine sich drehende Kurvenscheibe mit einem Stößel, der gleitet oder schwenkt, während die Kontur der Scheibe unter ihm durchläuft. Für Nocken, Exzenter und Ventiltriebe.", + "help.joint.create": "Verbindet zwei Teile an den gewählten Gelenkursprüngen und bewegt das zweite Teil an seine Stelle; der Gelenktyp legt fest, wie sie sich noch bewegen dürfen. Damit bauen Sie eine Baugruppe, deren Teile sich wie der echte Mechanismus bewegen.", + "help.joint.drive": "Setzt Winkel oder Weg eines Gelenks und bewegt die Teile mit, innerhalb der Grenzen; gekoppelte Gelenke und geschlossene Schleifen folgen. Damit prüfen Sie, ob sich ein Mechanismus wie gewünscht bewegt.", + "help.joint.gear": "Koppelt zwei Drehgelenke mit einer Übersetzung, sodass das Antreiben des einen das andere dreht. Für Stirn- und Hohlräder, mit der Übersetzung aus Teilkreisradien oder Zähnezahlen.", + "help.joint.limits": "Legt fest, wie weit sich ein Gelenk drehen oder schieben darf, als Minimum und Maximum, oder hebt diese Grenzen auf. Damit bleibt ein Scharnier, Schieber oder Hebel in seinem echten Bewegungsbereich.", + "help.joint.motion_link": "Koppelt zwei Gelenke über ein Verhältnis und einen Versatz, sodass das Antreiben des einen das andere bewegt. Für feste Beziehungen zwischen Gelenken, die Zahnrad, Zahnstange und Ritzel oder Schraube nicht abdecken.", + "help.joint.origin": "Setzt einen benannten Gelenkursprung auf ein Teil: in die Mitte einer Fläche oder eines runden Endes, auf die Mitte einer Kante, an eine Ecke oder einen Punkt. Damit bereiten Sie vor, wo Gelenke ansetzen; auf einer Fläche gewählt, folgt er ihr, wenn sich die Größe des Teils ändert.", + "help.joint.rack_pinion": "Koppelt ein drehendes Ritzel mit einer gleitenden Zahnstange, die sich um die abgewälzte Länge des Teilkreises verschiebt. Für Zahnstangenantriebe, Lenkungen und Lineartische.", + "help.joint.reach": "Bewegt einen Punkt eines Teils auf ein Ziel zu, indem die Gelenke seiner Kette innerhalb ihrer Grenzen verstellt werden. Damit stellen Sie einen Mechanismus über die Lage seines Endes ein, etwa der Spitze eines Roboterarms.", + "help.joint.rigid_group": "Verbindet die gewählten Teile starr in ihrer jetzigen Lage, sodass sie sich als Einheit bewegen. Für Teile, die zusammengehören und sich gegeneinander nicht bewegen.", + "help.joint.screw": "Koppelt Drehen und Schieben über eine Steigung, sodass jede Umdrehung um diesen Weg vorrückt. Für Gewindespindeln, Schrauben und Trapezspindeln.", + "help.library.apply": "Wendet ein Material oder eine Darstellung aus einer lokalen Bibliotheksdatei auf Körper, Flächen oder Komponenten an. Damit nutzen Sie Materialien aus einem anderen Design wieder.", + "help.library.export": "Speichert die Materialien und Darstellungen des Designs in einer Bibliotheksdatei. Damit teilen Sie sie oder nutzen sie in anderen Designs wieder.", + "help.library.inspect": "Prüft eine Bibliotheksdatei und listet ihre Materialien und Darstellungen auf, ohne das Design zu ändern. Damit sehen Sie vor dem Anwenden, was eine Bibliothek enthält.", "help.licences": "Lizenzen", "help.material.assign": "Legt fest, aus welchem Werkstoff die ausgewählten Körper bestehen, beispielsweise Stahl, Aluminium oder ABS. Dadurch entspricht die berechnete Masse derjenigen des realen Bauteils.", + "help.material.properties.validate": "Prüft eine Materialdatenkarte auf stimmige Werte und fehlende Angaben. Bevor Sie sich für Masse oder Steifigkeit auf die Daten verlassen.", + "help.mesh.convert": "Baut einen Netzkörper, etwa aus einer STL, als bearbeitbaren Volumenkörper mit ebenen und zylindrischen Flächen nach. Damit ändern Sie importierte Maschinenteile wie einen modellierten Körper.", "help.mit": "MIT-Lizenz", + "help.motion.study": "Legt Gelenkwerte an Schritten entlang einer Zeitleiste fest und spielt sie als Animation ab. Damit sehen Sie einen Mechanismus einen Ablauf durchlaufen und prüfen unterwegs die Abstände.", + "help.motion.trace": "Verfolgt die Bahn eines Punkts auf einem Teil, während eine Bewegungsuntersuchung läuft oder ein Gelenk angetrieben wird, und behält sie beim Bestätigen als 3D-Skizzenkurve. Damit sehen Sie, wohin ein Mechanismus reicht, oder bauen auf seiner Bahn auf.", "help.notice": "NOTICE", + "help.occurrence.ground": "Setzt ein Teil an seiner Stelle fest oder gibt es wieder frei, sodass Gelenke die übrigen Teile um es herum bewegen. Für den Rahmen oder Sockel, auf dem der Rest der Baugruppe aufbaut.", "help.occurrence.move": "Bewegt eine Baugruppeninstanz ausschließlich entlang der Freiheitsgrade, die ihre Gelenke zulassen. Wählen Sie eine Fläche des Bauteils aus und ziehen Sie anschließend einen der verfügbaren Pfeile oder Bögen.", "help.parameters.change": "Öffnet die Tabelle mit benannten Parametern und Ausdrücken, die die Abmessungen des Designs steuern. Ändern Sie einen Wert an einer zentralen Stelle, werden alle Features, die diesen Wert verwenden, entsprechend aktualisiert.", + "help.parameters.export": "Speichert die Parameter des Designs als CSV oder JSON. Damit bearbeiten Sie sie in einer Tabellenkalkulation oder übertragen sie in ein anderes Design.", + "help.parameters.graph": "Zeigt, welche Parameter und Elemente welche anderen nutzen, samt Schleifen oder Fehlern. Damit sehen Sie, was eine Parameteränderung beeinflusst.", + "help.parameters.import": "Liest Parameter aus CSV oder JSON in das Design ein, alle oder keine. Damit übernehmen Sie Werte aus einer Tabelle oder einem anderen Design.", + "help.parameters.move": "Verschiebt einen Benutzerparameter vor oder hinter einen anderen in der Liste. So bleiben zusammengehörige Parameter beieinander.", + "help.parts.fastener": "Fügt eine genormte Schraube, Mutter, Unterlegscheibe, einen Stift oder ein Lager in der gewählten Größe ein. Auf ein Loch gesetzt, sitzt es mit einem Gelenk darin, sodass Sie Verbindungselemente nicht selbst modellieren müssen.", + "help.parts.insert": "Fügt ein Normteil in der gewählten Art, Größe und Länge ein. Auf den Rand oder die Wand eines Lochs gesetzt, sitzt es mit einem starren Gelenk auf dem Loch.", + "help.parts.library": "Öffnet die Standardbauteile: Schrauben, Muttern, Unterlegscheiben, Gewindestifte, Passstifte und Lager in Normgrößen. Damit fügen Sie ein Normteil ein, statt es selbst zu modellieren.", + "help.parts.resize": "Ändert Größe oder Länge eines eingefügten Normteils und baut es neu auf; seine Gelenke halten es an seinem Platz. Für den Fall, dass eine Schraube zu lang ist oder ein Loch eine andere Größe braucht.", + "help.plastic.assign_rule": "Weist Körpern eine Kunststoffregel zu, sodass ihre Kunststoffelemente deren Formschräge und Spiel als Vorgabe nehmen. Damit richten Sie ein Spritzgussteil einmal für alle seine Elemente ein.", + "help.plastic.boss": "Fügt einen Ansatz (Schraubdom) auf einer Fläche hinzu: einen Zapfen mit optionaler Bohrung, Formschräge, Fußrundung und Stützrippen. Für Schraubstellen in gespritzten oder gedruckten Gehäusen.", + "help.plastic.lip": "Fügt entlang des Rands eines ausgehöhlten Körpers eine Lippe oder eine passende Nut hinzu. Damit zentrieren sich zwei Gehäusehälften aufeinander und schließen dicht.", + "help.plastic.manage_rules": "Erstellt und bearbeitet Kunststoffregeln: Material, Wandstärke, Radien, Spiel und Formschräge. Damit halten Sie die Elemente eines Spritzgussteils einheitlich.", + "help.plastic.rest": "Fügt eine Auflage auf einer Fläche hinzu: ein rundes oder rechteckiges Polster, voll oder hohl, mit Formschräge. Als Stütze oder Abstandshalter in einem Spritzgussgehäuse.", + "help.plastic.snap_fit": "Fügt eine Rastverbindung auf einer Fläche hinzu: einen Schnapphaken mit Länge, Dicke und Rastnase. Damit klipsen Sie Teile ohne Schrauben zusammen.", "help.pointcloud.insert": "Fügt einen PLY-, XYZ- oder PTS-Scan als Punktwolke neben dem Modell ein. Verwenden Sie ihn als Referenz beim Modellieren anhand von Messdaten.", + "help.sheet.convert": "Macht aus einer Platte oder einem ausgehöhlten Körper gleichmäßiger Dicke einen Blechkörper, mit Biegungen an den gewählten Kanten. Damit wird ein vorhandenes Teil abwickelbar.", + "help.sheet.export_bend_table": "Speichert die Biegungstabelle eines Blechkörpers als CSV oder JSON: Winkel, Radien und Lage der Biegungen im Flachmuster. Für die Abkantpresse oder die Werkstatt.", + "help.sheet.export_dxf": "Speichert das Flachmuster als DXF, mit Konturen, Biegelinien und optionalen Biegevermerken. Für Laser-, Plasma- oder Wasserstrahlschneiden.", + "help.sheet.flange": "Fügt einen Flansch hinzu: eine Grundplatte aus einer Skizze, eine Wand entlang einer offenen Skizzenkontur oder eine gebogene Wand an Blechkanten. Damit bauen Sie Blechteile auf.", + "help.sheet.flat_pattern": "Erstellt das Flachmuster eines Blechkörpers mit Größe, Ausschnitten und Biegelinien. Damit prüfen Sie den Zuschnitt, bevor Sie ihn schneiden.", + "help.sheet.fold": "Faltet ein Blech entlang einer Skizzenlinie um einen Winkel und Radius. Damit falten Sie ein flaches Blech, nachdem Sie Elemente hineingeschnitten haben.", + "help.sheet.hem": "Faltet eine Blechkante auf sich selbst zurück. Für sichere, steife Kanten ohne scharfen Grat.", + "help.sheet.manage_rules": "Erstellt und bearbeitet Blechregeln: Dicke, Biegeradius, K-Faktor, Freischnitte und Spalte. Damit passen Sie die Teile an Material und Werkzeuge Ihrer Werkstatt an.", + "help.sheet.path_flange": "Fügt entlang einer Blechkante eine Kette von Flanschen hinzu, die einer offenen Kette von Skizzenlinien folgt. Für Profile wie eine U- oder Z-Form in einem Schritt.", + "help.sheet.perforate": "Fügt über das ganze flache Blech ein Lochfeld hinzu, versetzt oder quadratisch. Für Lüftungen, Gitter und leichtere Bleche.", + "help.sheet.refold": "Faltet einen entfalteten Blechkörper wieder auf. Nachdem Sie dem flachen Blech Elemente hinzugefügt haben.", + "help.sheet.unfold": "Entfaltet alle Biegungen eines Blechkörpers. Damit fügen Sie Bohrungen oder Schnitte hinzu, die über eine Biegung laufen müssen, und falten dann wieder.", + "help.sketch.arc.center_point": "Zeichnet einen Kreisbogen um einen Mittelpunkt, von einem Start- zu einem Endpunkt. Für Bögen mit bekanntem Mittelpunkt, etwa einen Radius um eine Bohrung oder einen Drehpunkt.", + "help.sketch.arc.tangent": "Zeichnet einen Bogen, der am Ende einer Linie oder eines Bogens tangential weiterläuft. Damit setzen Sie eine Kette mit einer weichen Biegung statt einer Ecke fort.", + "help.sketch.arc.three_point": "Zeichnet einen Bogen von einem Start- zu einem Endpunkt durch einen dritten Punkt. Für Bögen, die durch vorgegebene Punkte laufen sollen statt um einen bekannten Mittelpunkt.", + "help.sketch.arc3d": "Zeichnet bei eingeschalteter 3D-Skizze einen exakten Kreisbogen im Raum durch drei Punkte. Für Biegungen in Pfaden und Leitkurven, die die Skizzenebene verlassen.", + "help.sketch.auto_constrain": "Fügt die Bedingungen hinzu, die die Skizze schon fast erfüllt, etwa horizontal, parallel oder koinzident, und bemaßt sie dann vollständig. Damit legen Sie eine grob gezeichnete Skizze in einem Schritt fest.", + "help.sketch.auto_constrain_from_datum": "Arbeitet wie Auto-Bedingung, misst die Lagen aber von einem gewählten Bezugspunkt statt vom Ursprung. Für Teile, die von einem Bezugspunkt aus bemaßt werden, etwa einer Ecke oder einer Bohrung.", + "help.sketch.blend_curve": "Verbindet die Enden zweier Kurven mit einem glatten Spline, der an beide tangential oder auf Wunsch auch krümmungsstetig anschließt. Damit überbrücken Sie eine Lücke zwischen Kurven ohne Knick.", + "help.sketch.break": "Teilt eine Kurve an den Stellen, an denen andere Kurven sie kreuzen, oder an Punkten, die Sie auf ihr setzen. Wenn ein Stück der Kurve eine eigene Bedingung braucht oder einzeln gelöscht werden soll.", + "help.sketch.cancel": "Verlässt die Skizze und verwirft alles, was seit dem Erstellen oder Öffnen zum Bearbeiten geändert wurde. Mit Rückgängig holen Sie die Änderungen zurück.", + "help.sketch.centerline": "Schaltet Linien zwischen normalen Linien und Mittellinien um, also Achsen, die nicht zu Profilen gehören. Eine Mittellinie dient als Achse für Drehen, Spiegeln oder eine Symmetriebedingung.", + "help.sketch.chamfer.distance_angle": "Schrägt die Ecke zwischen zwei Linien ab, bestimmt durch einen Abstand entlang der ersten Linie und einen Winkel. Wenn eine Zeichnung die Fase als Länge und Winkel angibt, etwa 2 mm × 30°.", + "help.sketch.chamfer.equal_distance": "Schrägt die Ecke zwischen zwei Linien mit gleichem Abstand auf beiden Seiten ab. Für die übliche 45°-Fase an einer Kontur.", + "help.sketch.chamfer.two_distance": "Schrägt die Ecke zwischen zwei Linien mit je einem eigenen Abstand entlang jeder Linie ab. Für Fasen, die nicht 45° haben und mit zwei Längen angegeben sind.", + "help.sketch.circle.center": "Zeichnet einen Kreis aus Mittelpunkt und Durchmesser. Für Bohrungen, Zapfen und andere runde Formen mit bekanntem Mittelpunkt.", + "help.sketch.circle.three_point": "Zeichnet einen Kreis durch drei Punkte. Wenn der Kreis durch vorgegebene Punkte laufen soll, etwa auf vorhandener Geometrie.", + "help.sketch.circle.three_tangent": "Zeichnet einen Kreis, der drei Linien, Kreise oder Bögen berührt. Damit setzen Sie einen Kreis passend zwischen drei Kurven, etwa den Inkreis eines Dreiecks.", + "help.sketch.circle.two_point": "Zeichnet einen Kreis aus den beiden Enden seines Durchmessers. Wenn die Randpunkte des Kreises bekannt sind statt seines Mittelpunkts.", + "help.sketch.circle.two_tangent": "Zeichnet einen Kreis vorgegebener Größe, der zwei Linien, Kreise oder Bögen berührt. Für eine Rundung mit festem Radius zwischen zwei Kurven.", + "help.sketch.conic": "Zeichnet einen Kegelschnitt zwischen zwei Endpunkten, geformt durch einen Scheitelpunkt und einen Rho-Wert. Für weiche Übergänge, deren Völle Sie einstellen wollen, von flach bis fast zur scharfen Ecke.", + "help.sketch.constrainer": "Setzt die Bedingung, die eine oder zwei gewählte Kurven schon fast erfüllen, etwa horizontal, parallel, tangential oder gleich. Damit räumen Sie eine frei gezeichnete Skizze Beziehung für Beziehung auf.", + "help.sketch.constraint.coincident": "Legt einen Punkt auf einen anderen Punkt, eine Kurve oder eine Skizzenachse. Damit schließen Sie Lücken in einem Profil oder setzen einen Punkt auf eine Linie.", + "help.sketch.constraint.collinear": "Legt zwei Linien auf eine gemeinsame Gerade. Damit richten Sie unterbrochene Kanten aus, etwa die beiden Seiten einer Kerbe.", + "help.sketch.constraint.concentric": "Gibt zwei Kreisen oder Bögen denselben Mittelpunkt. Für Ringe, Senkungen und abgesetzte Wellen.", + "help.sketch.constraint.curvature": "Lässt zwei Kurven, die sich an einem Endpunkt treffen, mit gleicher Krümmung ineinander übergehen (G2). Wo ein tangentialer Übergang nicht glatt genug ist, etwa an Sichtflächen.", + "help.sketch.constraint.equal": "Macht Linien gleich lang oder Kreise und Bögen gleich groß im Radius. So steuert ein Maß mehrere gleiche Elemente.", + "help.sketch.constraint.fix": "Fixiert Punkte oder Kurven an ihrer Stelle oder löst sie wieder. Für Geometrie, die sich beim Ziehen oder Lösen der Skizze nicht bewegen darf.", + "help.sketch.constraint.horizontal_vertical": "Macht eine Linie oder die Verbindung zweier Punkte horizontal oder vertikal, je nachdem, was näher liegt. Damit richten Sie eine Kontur an den Skizzenachsen aus.", + "help.sketch.constraint.midpoint": "Setzt einen Punkt auf die Mitte einer Linie oder lässt die Mittelpunkte zweier Linien zusammenfallen. Damit mitten Sie Geometrie auf einer Kante ein.", + "help.sketch.constraint.parallel": "Macht zwei Linien parallel. Für Seiten, die parallel bleiben müssen, wenn sich andere Maße ändern.", + "help.sketch.constraint.perpendicular": "Stellt zwei Linien rechtwinklig zueinander. Für rechte Winkel zwischen Linien, die nicht an den Skizzenachsen liegen.", + "help.sketch.constraint.polygon": "Macht eine geschlossene Linienkette zum regelmäßigen Polygon: gleich lange Seiten, alle Ecken auf einem Kreis. Damit machen Sie ein frei gezeichnetes Vieleck regelmäßig und halten es so.", + "help.sketch.constraint.symmetry": "Lässt zwei Punkte oder Kurven an einer Linie oder einem Punkt gespiegelt liegen. So bleibt ein Profil symmetrisch, wenn Sie eine Seite ändern.", + "help.sketch.constraint.tangent": "Lässt zwei Kurven tangential aneinanderstoßen, etwa eine Linie und einen Bogen. Für glatte Übergänge ohne Knick.", + "help.sketch.construction": "Schaltet Kurven zwischen normaler Geometrie und Konstruktionsgeometrie um, die nicht zu Profilen gehört. Konstruktionskurven dienen als Hilfen für Aufteilung, Bezüge und Maße.", "help.sketch.construction_line": "Erstellt eine unendliche Konstruktionslinie durch zwei Punkte, ohne Skizzenprofile zu teilen.", "help.sketch.construction_line.angle": "Erstellt eine unendliche Konstruktionslinie in einem Winkel zur Skizzen-X-Achse. Punkt wählen und Winkel eingeben oder Richtung wählen.", "help.sketch.construction_line.bisector": "Zwei sich schneidende gerade Skizzenlinien wählen, dann eine der beiden Winkelhalbierenden. Das Ergebnis folgt den Ausgangslinien.", @@ -1415,12 +1571,71 @@ "help.sketch.construction_line.tangent_external": "Kreis oder Kreisbogen wählen, dann einen äußeren Punkt und eine Tangentenlösung. Die Vorschau zeigt nur Berührpunkte auf dem gezeichneten Bogen.", "help.sketch.construction_line.tangent_two": "Zwei Kreise oder Kreisbögen wählen, dann eine gemeinsame Tangente in der Vorschau. Die Berührpunkte müssen auf den gezeichneten Kurven liegen.", "help.sketch.construction_line.vertical": "Erstellt eine unendliche vertikale Konstruktionslinie durch einen Punkt. Die Vertikalbedingung bleibt bei späteren Änderungen erhalten.", + "help.sketch.copy": "Kopiert die gewählten Skizzenkurven und -punkte samt den Bedingungen und Maßen zwischen ihnen. Fügen Sie sie in diese oder eine andere Skizze ein.", + "help.sketch.create": "Beginnt eine neue 2D-Skizze auf einer Ebene oder einer ebenen Fläche des Modells. Jede Extrusion, Drehung und Austragung beginnt mit einer Skizze.", + "help.sketch.cut": "Kopiert die gewählte Skizzengeometrie und löscht sie aus der Skizze, in einem Rückgängig-Schritt. Fügen Sie sie an anderer Stelle oder in eine andere Skizze ein.", + "help.sketch.dimension": "Fügt ein steuerndes Maß hinzu: Länge, Abstand, Winkel, Radius oder Durchmesser, je nachdem, was Sie wählen. Geben Sie einen Wert oder Parameternamen ein, und die Skizze folgt ihm.", + "help.sketch.edit": "Öffnet eine vorhandene Skizze zum Bearbeiten. Die darauf aufbauenden Elemente werden aktualisiert, wenn Sie die Skizze fertigstellen.", + "help.sketch.ellipse": "Zeichnet eine Ellipse aus Mittelpunkt, dem Ende der Hauptachse und einem Punkt auf der Nebenseite. Für ovale Öffnungen, Nocken und andere längliche Rundformen.", "help.sketch.equation_curve": "Zeichnet eine Kurve aus Formeln über einen Bereich: x(t) und y(t) oder y = f(x). Die Formeln können Parameter verwenden, und die Kurve folgt ihnen: für eine Sinuswelle, eine Spirale oder die Evolvente eines Zahnrads.", + "help.sketch.extend": "Verlängert eine Linie oder einen Bogen bis zur nächsten Kurve im Weg oder zwei Kurven, bis sie sich treffen. Damit schließen Sie Lücken in einem Profil nach dem Kürzen oder Ändern.", + "help.sketch.fillet": "Rundet die Ecke zwischen zwei Linien oder Bögen mit einem Bogen vorgegebenen Radius ab. Mehrere zusammen gerundete Ecken teilen sich einen Radius.", + "help.sketch.finish": "Verlässt die Skizze und kehrt zum Modell zurück. Ihre geschlossenen Profile stehen dann für Extrusion, Drehung und weitere Elemente bereit.", + "help.sketch.finish_auto_constrain": "Wendet Auto-Bedingung auf die Skizze an und stellt sie dann fertig. Damit verlassen Sie eine Skizze in einem Schritt vollständig bestimmt.", + "help.sketch.fit_curves_to_section": "Schneidet einen Netz- oder Volumenkörper mit der Skizzenebene und passt Linien und Bögen an den Querschnitt an. Damit bauen Sie ein gescanntes oder importiertes Teil als bearbeitbare Skizze nach.", + "help.sketch.include_3d": "Holt Modellkanten, Eckpunkte und Kurven anderer Skizzen als verknüpfte Bezüge in diese Skizze und legt Teile außerhalb der Ebene flach auf sie. Damit bauen Sie auf Geometrie auf, die nicht in der Skizzenebene liegt.", + "help.sketch.infinite": "Schaltet Linien zwischen normalen Linien und unendlichen Konstruktionslinien um, die über die ganze Ansicht laufen. Als Bezugslinien für die Aufteilung, die nie zu kurz enden.", + "help.sketch.insert_dxf": "Liest die Kurven einer DXF-Zeichnung in eine Skizze ein, skaliert, gedreht und an der gewünschten Stelle. Damit bauen Sie auf einer 2D-Zeichnung aus einem anderen Programm auf, etwa einer Laserschnitt-Kontur.", + "help.sketch.insert_svg": "Liest die Pfade einer SVG-Zeichnung in eine Skizze ein, skaliert, gedreht und an der gewünschten Stelle. Damit machen Sie aus einem Logo oder einer Vektorkontur Kurven, die sich extrudieren lassen.", "help.sketch.inspect": "Öffnet einen Bericht zur offenen Skizze: wie viele Freiheitsgrade übrig sind und ob sie vollständig bestimmt ist. Damit sehen Sie, was sich noch bewegt, bevor Sie die Skizze abschließen.", + "help.sketch.intersect": "Fügt die Kurven oder Punkte hinzu, an denen Körper, Flächen, Kanten oder andere Skizzen die Skizzenebene schneiden. Sie bleiben verknüpft und folgen Änderungen am Modell.", + "help.sketch.intersection_curve": "Erzeugt 3D-Kurven, wo sich zwei Körper oder Flächen treffen, mit diesen verknüpft. Als Pfade oder Leitkurven, die dem Modell folgen.", + "help.sketch.isoparametric_curve": "Erzeugt eine 3D-Kurve in U- oder V-Richtung einer Fläche durch einen gewählten Punkt, mit der Fläche verknüpft. Als Pfad oder Trennlinie, die der Fläche folgt.", + "help.sketch.line": "Zeichnet verbundene Linien Punkt für Punkt, als offene Kette oder geschlossene Kontur. Das Grundwerkzeug für die meisten Profile.", + "help.sketch.line.midpoint": "Zeichnet eine Linie von ihrem Mittelpunkt zu einem Ende, das andere Ende gespiegelt. Für Linien, die auf einem Punkt mittig liegen, etwa eine symmetrische Kante.", + "help.sketch.line3d": "Zeichnet bei eingeschalteter 3D-Skizze verbundene gerade Linien zwischen Punkten im Raum. Für Pfade und Rahmen, die die Skizzenebene verlassen.", + "help.sketch.mirror": "Spiegelt Skizzenkurven und -punkte an einer Linie oder Achse und fügt Symmetriebedingungen hinzu. Damit zeichnen Sie ein symmetrisches Profil zur Hälfte und ergänzen es.", + "help.sketch.move": "Verschiebt oder dreht Skizzenkurven und -punkte oder eine Kopie davon. Damit setzen Sie Geometrie um, ohne Form und Bedingungen zu verlieren.", + "help.sketch.offset": "Erzeugt eine Kopie einer Kurvenkette in festem Abstand, auf einer oder beiden Seiten. Für Wände, Konturen und Spiele; der Abstand bleibt ein änderbares Maß.", + "help.sketch.paste": "Fügt kopierte oder ausgeschnittene Skizzengeometrie in die aktive Skizze ein. Setzen Sie sie an einen Punkt oder verschieben Sie sie um einen Versatz.", + "help.sketch.pattern.circular": "Kopiert Skizzengeometrie um einen Mittelpunkt, über einen vollen Kreis oder einen festen Winkel. Für Lochkreise, Speichen und Zähne.", + "help.sketch.pattern.rectangular": "Kopiert Skizzengeometrie in Reihen und Spalten mit festen Abständen. Für Raster aus Bohrungen, Schlitzen oder Lüftungsöffnungen.", + "help.sketch.point": "Setzt einen Skizzenpunkt. Als Bezug für Maße, Bedingungen, Bohrungsmitten oder spätere Elemente.", + "help.sketch.point3d": "Setzt bei eingeschalteter 3D-Skizze einen Punkt im Raum. Als Bezug für 3D-Kurven und Pfade.", + "help.sketch.polygon.circumscribed": "Zeichnet ein regelmäßiges Polygon um einen Kreis, bemessen vom Mittelpunkt zur Mitte einer Seite. Wenn die Schlüsselweite bekannt ist, etwa bei einer Sechskantmutter.", + "help.sketch.polygon.edge": "Zeichnet ein regelmäßiges Polygon aus den beiden Enden einer Seite. Wenn Länge und Lage einer Seite bekannt sind.", + "help.sketch.polygon.inscribed": "Zeichnet ein regelmäßiges Polygon in einen Kreis, bemessen vom Mittelpunkt zu einer Ecke. Wenn das Eckmaß bekannt ist.", + "help.sketch.project": "Projiziert Modellkanten, Flächen, Eckpunkte und Kurven anderer Skizzen auf die Ebene dieser Skizze. Sie bleiben verknüpft und folgen Änderungen am Modell.", + "help.sketch.project_to_surface": "Projiziert Kurven anderer Skizzen entlang der Normalen dieser Skizze auf eine Fläche, als verknüpfte 3D-Kurven. Damit bringen Sie ein Logo, eine Trennlinie oder einen Pfad auf eine gekrümmte Fläche.", + "help.sketch.rectangle.center": "Zeichnet ein Rechteck aus Mittelpunkt und einer Ecke. Für Konturen, die mittig auf einem Punkt liegen, etwa dem Ursprung.", + "help.sketch.rectangle.rounded": "Zeichnet ein Rechteck, dessen vier Ecken mit einem Radius gerundet sind. Für Platten, Deckel und Öffnungen mit runden Ecken.", + "help.sketch.rectangle.three_point": "Zeichnet ein Rechteck aus einer Kante und einem Punkt auf der gegenüberliegenden Seite. Für Rechtecke, die schräg zu den Skizzenachsen liegen.", + "help.sketch.rectangle.two_point": "Zeichnet ein Rechteck aus zwei gegenüberliegenden Ecken. Für die Konturen von Platten, Blöcken und Taschen.", + "help.sketch.scale": "Skaliert Skizzenkurven und -punkte mit einem Faktor um einen Basispunkt. Damit ändern Sie die Größe eines Skizzenteils, ohne ihn neu zu zeichnen.", + "help.sketch.slot.arc_center": "Zeichnet einen gebogenen Schlitz vorgegebener Breite entlang eines Bogens aus Mittelpunkt, Start und Ende. Für Verstellschlitze um einen Drehpunkt.", + "help.sketch.slot.arc_three_point": "Zeichnet einen gebogenen Schlitz vorgegebener Breite entlang eines Bogens durch drei Punkte. Wenn der Verlauf des Schlitzes durch vorgegebene Punkte gehen soll.", + "help.sketch.slot.center_point": "Zeichnet einen geraden Schlitz vorgegebener Breite von seiner Mitte zum Mittelpunkt eines Endbogens. Für Schlitze, die mittig auf einem Punkt liegen.", + "help.sketch.slot.center_to_center": "Zeichnet einen geraden Schlitz vorgegebener Breite zwischen den Mittelpunkten seiner beiden Endbögen. Für Schlitze, die von Mitte zu Mitte bemaßt sind, etwa Langlöcher für Schrauben.", + "help.sketch.slot.overall": "Zeichnet einen geraden Schlitz vorgegebener Breite von einem äußeren Ende zum anderen. Wenn es auf die Gesamtlänge des Schlitzes ankommt.", + "help.sketch.spline.control_point": "Zeichnet einen kubischen Spline, den Kontrollpunkte formen, ohne dass er durch sie läuft. Für glatte, fließende Kurven, die Sie durch Verschieben der Punkte formen.", + "help.sketch.spline.control_point_5": "Zeichnet einen Spline vom Grad 5, den Kontrollpunkte formen. Wo Kurven mit glatterer Krümmung anschließen müssen, etwa an gestalteten Sichtflächen.", + "help.sketch.spline.fit_point": "Zeichnet einen glatten Spline durch alle gesetzten Punkte, offen oder geschlossen. Damit zeichnen Sie eine Form durch bekannte Punkte nach.", + "help.sketch.spline3d": "Zeichnet bei eingeschalteter 3D-Skizze eine glatte Kurve durch Punkte im Raum. Für Austragungspfade und Kabelwege, die die Skizzenebene verlassen.", + "help.sketch.spun_profile": "Erzeugt in der Skizzenebene die Kontur, die ein Körper bei einer Drehung um eine Achse überstreicht. Damit erhalten Sie den Umriss eines Drehteils für eine Drehung oder eine Zeichnung.", + "help.sketch.text": "Schreibt Text in die Skizze, mit Schriftart, Höhe und Winkel; der Wortlaut kann Parametern folgen. Extrudieren oder prägen Sie ihn für Beschriftungen und Schriftzüge.", + "help.sketch.transform": "Skaliert, dreht und verschiebt Skizzengeometrie um einen Mittelpunkt in einem Schritt. Damit setzen Sie eine Auswahl gleichzeitig um und ändern ihre Größe.", + "help.sketch.trim": "Entfernt das Stück einer Kurve zwischen den nächsten Schnittpunkten um die angeklickte Stelle. Damit räumen Sie sich überlappende Linien zu einem geschlossenen Profil auf.", "help.solid.align": "Verschiebt die ausgewählten Körper so, dass ein Punkt, eine Fläche oder eine Ebene eines Körpers mit einem entsprechenden Punkt, einer Fläche oder Ebene an anderer Stelle zusammenfällt. So können Sie Bauteile aneinander ausrichten, ohne Abstände messen zu müssen.", + "help.solid.boundary_fill": "Erzeugt Volumenkörper aus den Bereichen, die sich überlappende Werkzeugkörper einschließen, und füllt nur die gewählten Bereiche. Damit bauen Sie eine Form aus dem Raum, den mehrere Körper gemeinsam begrenzen.", + "help.solid.bounding_solid": "Erzeugt einen Quader, der die gewählten Körper umschließt, auf Wunsch mit Aufmaß. Damit sehen Sie die Gesamtgröße eines Teils oder erhalten das Rohteil, aus dem es gefertigt wird.", + "help.solid.box": "Erzeugt einen Quader aus Länge, Breite und Höhe, ab einem Eckpunkt oder um einen Punkt zentriert. Für einen schnellen Block, einen Platzhalter oder einen Körper, in den Sie Features schneiden.", "help.solid.chamfer": "Schneidet die gewählten Kanten mit einer ebenen Fase im eingestellten Abstand ab. Damit brechen Sie scharfe Kanten oder erleichtern das Einführen eines Stifts oder einer Schraube.", + "help.solid.coil": "Erzeugt einen schraubenförmigen Körper, etwa eine Feder, aus Durchmesser, Windungen, Höhe oder Steigung und einem runden oder eckigen Querschnitt. Für Federn, Spulen und schraubenförmige Stege.", "help.solid.combine": "Kombiniert Körper: vereinigt sie, zieht sie von einem Zielkörper ab oder behält nur das gemeinsame Volumen; die Werkzeugkörper können erhalten bleiben. Damit fügen Sie Teile zusammen, schneiden eine Form mit einer anderen aus oder finden, wo sich zwei Körper überschneiden.", + "help.solid.cone": "Erzeugt einen Kegel aus Grundkreis und Höhe, oder einen Kegelstumpf, wenn auch die Oberseite einen Radius hat. Für Verjüngungen, Trichter, Senkungen und Spitzen.", + "help.solid.cylinder": "Erzeugt einen Zylinder aus Radius oder Durchmesser und Höhe, auf einem Punkt entlang einer Achse stehend. Für Wellen, Stifte, Dome und runde Rohlinge.", "help.solid.draft": "Schrägt die ausgewählten Flächen um den eingestellten Winkel gegenüber einer neutralen Ebene ab. Diese Funktion wird bei Guss- und Spritzgussteilen verwendet, damit sich die Bauteile leichter aus der Form lösen lassen.", + "help.solid.emboss": "Hebt Skizzenprofile wie Text oder ein Logo aus einer Fläche heraus oder senkt sie hinein. Für Beschriftungen, Etiketten und Markierungen auf ebenen oder runden Flächen.", "help.solid.extrude": "Zieht ein Skizzenprofil über eine Strecke in die dritte Dimension und fügt dabei Material hinzu oder entfernt es. Damit machen Sie aus einer flachen Kontur einen Block, eine Erhebung oder eine Tasche.", "help.solid.fillet": "Rundet die gewählten Kanten mit einem glatten Kreisübergang im eingestellten Radius ab. Damit entschärfen Sie scharfe Kanten für Festigkeit, Griffigkeit oder Optik.", "help.solid.hole": "Bohrt an einem gewählten Punkt auf einer Fläche ein rundes Loch im eingestellten Durchmesser. Für Durchgangsbohrungen für Schrauben, Stifte und andere einfache runde Öffnungen.", @@ -1429,20 +1644,60 @@ "help.solid.move": "Verschiebt oder dreht die ausgewählten Körper oder deren Kopien um den eingestellten Abstand und Winkel. So können Sie einen Körper neu positionieren oder an anderer Stelle eine Kopie erstellen.", "help.solid.offset_face": "Versetzt die ausgewählten Flächen um den eingestellten Abstand nach innen oder außen. Die angrenzenden Flächen werden entsprechend angepasst. So können Sie beispielsweise eine Wand verdicken, eine Tasche vertiefen oder einen Ansatz erhöhen, ohne die Skizze bearbeiten zu müssen.", "help.solid.pattern.circular": "Wiederholt die gewählten Objekte um eine Achse, mit der eingestellten Anzahl und dem Gesamtwinkel. Für Lochkreise, Speichen, Kühlrippen und Zähne.", + "help.solid.pattern.path": "Kopiert Elemente oder Körper entlang eines Skizzenpfads, nach Anzahl und Abstand. Für Bohrungen entlang einer gebogenen Kante oder Teile entlang einer Schiene.", "help.solid.pattern.rectangular": "Wiederholt die gewählten Objekte in einer oder zwei Richtungen mit der eingestellten Anzahl und dem Abstand. Für Reihen und Raster von Bohrungen, Rippen, Stiften oder Füßen.", + "help.solid.pipe": "Erzeugt ein rundes Rohr entlang eines Pfads aus Skizzenkurven, massiv oder hohl mit Wandstärke. Für Rohre, Drähte, Kabel und Geländer.", "help.solid.press_pull": "Fügt Material hinzu oder trägt es ab, indem Sie eine Fläche ziehen. Durch Ziehen an einer Kante können Sie diese abrunden. So lassen sich schnelle Änderungen direkt am Modell vornehmen.", "help.solid.remove": "Entfernt die ausgewählten Körper ab dieser Stelle der Timeline aus dem Design. In den vorherigen Schritten bleiben sie erhalten. So können Sie beispielsweise einen nicht mehr benötigten Hilfs- oder Schneidkörper entfernen.", "help.solid.replace_face": "Verschiebt die ausgewählten ebenen Flächen auf eine Ebene oder eine parallele Fläche. Der Körper wird dabei entsprechend vergrößert oder verkleinert. So können Sie eine Fläche bündig zu einem anderen Bauteil oder einer Konstruktionsebene ausrichten.", "help.solid.revolve": "Dreht ein Skizzenprofil um eine Achse und erzeugt so einen runden Körper. Für Wellen, Knöpfe, Schalen und alle Teile, die um eine Mittellinie rund sind.", + "help.solid.rib": "Fügt entlang einer offenen Skizzenkurve eine dünne Wand hinzu, die bis zu den nächsten Flächen des Körpers reicht. Damit versteifen Sie Wände und Ecken, etwa bei Spritzguss- oder Druckteilen.", "help.solid.scale": "Skaliert die ausgewählten Körper mit einem festgelegten Faktor, gleichmäßig in alle Richtungen oder getrennt entlang der einzelnen Achsen. So können Sie beispielsweise importierte Geometrien anpassen oder Materialschwindung berücksichtigen.", "help.solid.shell": "Höhlt einen Körper aus, lässt Wände in der eingestellten Dicke stehen und kann gewählte Flächen öffnen. Für Kästen, Deckel und andere dünnwandige Teile.", + "help.solid.sphere": "Erzeugt eine Kugel aus Radius oder Durchmesser um einen Mittelpunkt. Für Kugeln, Knäufe und abgerundete Enden.", "help.solid.split_body": "Teilt einen Körper mithilfe einer Ebene, der Fläche eines anderen Körpers oder einer Skizzenlinie in mehrere getrennte Körper. So können Sie Bauteile erstellen, die einzeln gedruckt, bearbeitet oder eingefärbt werden können.", "help.solid.split_face": "Teilt die ausgewählten Flächen entlang einer Ebene, der Fläche eines anderen Körpers oder von Skizzenkurven, ohne den Körper selbst zu teilen. So können Sie Teilbereiche einer Fläche mit einer eigenen Formschräge, Farbe oder einem Versatz versehen.", "help.solid.sweep": "Führt ein Skizzenprofil entlang eines Pfads und erzeugt so einen Körper. Für Rohre, Schienen, Griffe und alle Teile mit gleichbleibendem Querschnitt entlang einer Kurve.", + "help.solid.thread": "Versieht eine zylindrische Fläche mit einem metrischen ISO-Gewinde, als kosmetisches Gewinde oder in den Körper geschnitten. Für Schrauben, Gewindebohrungen und gedruckte Schraubteile.", + "help.solid.torus": "Erzeugt einen Ring aus einem großen Radius um seine Achse und einem kleinen Radius für seinen runden Querschnitt. Für O-Ringe, Griffe und runde Wülste.", + "help.solid.web": "Fügt entlang jeder offenen Skizzenkurve eine dünne Wand hinzu, jede bis zu den nächsten Flächen des Körpers. Für ein Gitter aus Stützwänden in Gehäusen und Schalen.", + "help.surface.blend": "Erzeugt eine Fläche, die die Kanten zweier Körper überbrückt, tangential oder krümmungsstetig zu beiden. Damit verbinden Sie zwei Flächen glatt über eine Lücke.", + "help.surface.boundary": "Erzeugt eine Fläche, die eine Schleife aus zwei bis vier Skizzenkurven oder ein Kurvennetz füllt. Für Freiformflächen, die durch ihre Ränder bestimmt sind.", + "help.surface.extend": "Verlängert eine Fläche über ihre Kanten hinaus, um einen Abstand oder bis zu einer Ebene oder Fläche. Damit überlappen sich Flächen, bevor Sie sie trimmen oder vernähen.", + "help.surface.extrude": "Zieht Skizzenkurven oder Profile über eine Strecke zu einer Fläche, nur Wände ohne Deckel. Für dünne Wände und als Werkzeug zum Schneiden oder Teilen.", + "help.surface.fair": "Glättet das Innere von Freiformflächen, während ihre Kanten an Ort und Stelle bleiben. Damit gleichen Sie Beulen und wellige Spiegelungen in einer Fläche aus.", + "help.surface.loft": "Erzeugt eine Fläche, die der Reihe nach durch zwei oder mehr Querschnitte läuft, ohne Deckel. Für Schalen und Übergänge zwischen verschiedenen Formen.", + "help.surface.match": "Baut die Kante einer Fläche so neu auf, dass sie an die Kante eines anderen Körpers anschließt, mit passender Tangente oder Krümmung. Damit schließen Sie eine Naht zwischen zwei Flächen glatt.", + "help.surface.offset": "Erzeugt eine Kopie von Flächen in einem Abstand entlang ihrer Normalen, als neuen Flächenkörper. Für Häute, Spiele oder die Gegenseite einer Wand.", + "help.surface.patch": "Füllt einen Skizzenbereich oder eine geschlossene Kantenschleife mit einer Fläche, eben oder glatt gekrümmt. Damit schließen Sie Löcher in einem Flächenkörper.", + "help.surface.reverse_normal": "Dreht Flächen eines Flächenkörpers um, sodass ihre Außenseite in die andere Richtung zeigt. Vor dem Aufdicken oder Vernähen, wenn die Flächen nach innen zeigen.", + "help.surface.revolve": "Dreht Skizzenkurven oder Profile um eine Achse zu einer Fläche, ohne Deckel. Für runde Schalen wie Lampenschirme, Schüsseln und Düsen.", + "help.surface.ruled": "Erzeugt einen Streifen aus geraden Linien, der von einer Kantenkette ausgeht, entlang der Flächennormalen, tangential oder unter einem Winkel. Für Schrägwände, Ausläufe und Trennflächen.", + "help.surface.stitch": "Vernäht Flächenkörper entlang gemeinsamer Kanten, zu einem Volumenkörper, wenn sie sich schließen. Damit machen Sie aus mehreren Flächen einen Körper.", + "help.surface.sweep": "Führt Skizzenkurven oder Profile entlang eines Pfads zu einer Fläche, ohne Deckel. Für Rinnen, Leisten und andere dünne Formen, die einem Pfad folgen.", + "help.surface.thicken": "Gibt einem Flächenkörper eine Dicke und macht ihn so zum Volumenkörper. Damit machen Sie aus Flächen druck- oder fräsbare Teile.", + "help.surface.trim": "Schneidet eine Fläche mit einer Ebene, einer anderen Fläche oder Skizzenkurven und behält die gewählte Seite. Damit schneiden Sie Flächen vor dem Vernähen in Form.", + "help.surface.unstitch": "Löst Flächen eines Körpers als eigene Flächenkörper heraus; ein Volumenkörper zerfällt dabei in Flächen. Damit überarbeiten Sie einzelne Flächen eines Körpers.", + "help.surface.untrim": "Entfernt Löcher aus einer Fläche oder stellt ihren äußeren Rand auf die natürlichen Grenzen der Fläche zurück. Damit holen Sie nach einem Trimmen die volle Fläche zurück.", "help.third_party": "Drittanbieterlizenzen", "help.timeline.compute_all": "Berechnet alle Features der Timeline von Anfang an neu. Dadurch wird das Modell aktualisiert und Sie sehen, bei welchen Features Berechnungsfehler auftreten.", "help.timeline.delete": "Löscht die ausgewählten Features aus der Timeline. Beim Löschen einer Skizze werden auch alle daraus erstellten Features gelöscht. So können Sie nicht mehr benötigte Konstruktionsschritte entfernen.", + "help.timeline.dependents": "Listet die Elemente, die mit einem Element gelöscht würden, und die, die ohne es fehlschlagen würden. Bevor Sie ein Element löschen oder ändern.", "help.timeline.play": "Die verbleibenden Features nacheinander abspielen, mit einer halben Sekunde Pause nach jedem Neuaufbau. Diesen Befehl erneut ausführen oder Escape drücken, um anzuhalten; Zum Ende springt sofort ans Ende.", + "help.view.apply": "Dreht die Ansicht in eine Standardrichtung wie vorne, oben oder iso oder zu einer gespeicherten Ansicht. Damit betrachten Sie das Modell aus einem festen Winkel.", + "help.view.delete": "Löscht eine gespeicherte Ansicht. Das Modell bleibt unverändert.", + "help.view.fit": "Zoomt und verschiebt so, dass das ganze Modell oder die Auswahl die Ansicht füllt. Wenn das Modell aus dem Blick geraten ist.", + "help.view.grid": "Blendet das Raster im Ansichtsfenster ein oder aus. Als Größen- und Orientierungshilfe beim Modellieren.", + "help.view.ground_shadow": "Blendet den Schatten des Modells auf dem Boden ein oder aus. Damit schätzen Sie Höhe und Lage leichter ein.", + "help.view.home": "Dreht die Ansicht zurück in die Ausgangsrichtung und zeigt das ganze Modell. Damit kommen Sie schnell zu einer bekannten Ansicht zurück.", + "help.view.list": "Listet die im Design gespeicherten Ansichten auf. Damit finden Sie eine früher gespeicherte Ansicht.", + "help.view.navigate": "Dreht, verschiebt oder zoomt die Ansicht schrittweise, etwa eine Vierteldrehung nach links. Damit bewegen Sie die Kamera genau statt durch Ziehen.", + "help.view.navigation_bar": "Blendet die Navigationsleiste unten im Ansichtsfenster ein oder aus. Ausblenden für mehr Platz, einblenden, um die Ansichtswerkzeuge per Maus zu erreichen.", + "help.view.rename": "Gibt einer gespeicherten Ansicht einen neuen Namen. Die Ansicht selbst bleibt gleich.", + "help.view.save": "Speichert die aktuelle Kameraposition als benannte Ansicht im Design. Damit kehren Sie später zum selben Blickwinkel zurück, etwa für Screenshots.", + "help.view.spin": "Dreht die Ansicht fortlaufend um das Modell, bis zum nächsten Klick oder Tastendruck. Damit präsentieren Sie ein Modell von allen Seiten.", + "help.view.tool": "Schaltet ein Kamerawerkzeug fest, Drehen, Verschieben oder Zoomen, sodass Ziehen mit der linken Maustaste die Ansicht bewegt, bis Sie es abschalten. Für Touchpads oder Mäuse ohne mittlere Taste.", + "help.view.visual_style": "Schaltet um, wie das Modell gezeichnet wird: schattiert mit oder ohne Kanten, Drahtmodell oder mit verdeckten Kanten. Drahtmodell zum Hineinschauen, schattiert für ein sauberes Bild.", "history.sketch_under_constrained": "Unterbestimmt ({count} Freiheitsgrade)", "history.sketch_under_constrained_one": "Unterbestimmt ({count} Freiheitsgrad)", "hole_presets.confirm_delete": "Löschen bestätigen", @@ -1877,6 +2132,8 @@ "prefs.ctrl_cmd": "Strg / Cmd", "prefs.curved_surfaces": "Gekrümmte Flächen", "prefs.curves": "Gekrümmte Flächen", + "prefs.dark_parts_shading": "Form dunkler Teile erhalten", + "prefs.dark_parts_shading_help": "Die Ansicht in linearem Licht mit sanftem Himmelslicht schattieren, damit dunkle Flächen einen Verlauf behalten statt schwarz zu werden", "prefs.dead_zone_note": "Der schraffierte Bereich kennzeichnet die Totzone. Bewegungen innerhalb dieses Bereichs werden ignoriert.", "prefs.default": "Standard", "prefs.design": "Design-Vorgaben", @@ -1891,6 +2148,8 @@ "prefs.edges": "Kanten und Schattierung", "prefs.extend": "Erweitern", "prefs.extra_navigation": "Mit Strg und gedrückter linker Maustaste auf einer freien Fläche drehen. Leertaste gedrückt halten, um die Ansicht zu verschieben.", + "prefs.extrude_view_isometric": "Beim Öffnen von Extrudieren isometrisch ausrichten", + "prefs.extrude_view_return": "Beim Schließen von Extrudieren zur vorigen Ansicht zurück", "prefs.follows_os": "Wie im Betriebssystem", "prefs.font": "Schrift", "prefs.font_fallback": "Zeichen, die in der ausgewählten Schrift fehlen, werden mit der integrierten Ersatzschrift dargestellt. So werden keine Beschriftungen als leere Kästchen angezeigt.", @@ -1906,6 +2165,7 @@ "prefs.graphics_card_auto": "Automatisch", "prefs.graphics_card_missing": "Die gespeicherte Grafikkarte ist nicht verfügbar. Stattdessen wird „Automatisch“ verwendet.", "prefs.graphics_card_restart": "Diese Einstellung wird nach einem Neustart von SolidCraft wirksam. Die Umgebungsvariable WGPU_ADAPTER_NAME hat Vorrang vor dieser Auswahl.", + "prefs.graphics_card_unused": "Die gespeicherte Grafikkarte wurde für dieses Fenster nicht verwendet; die tatsächlich genutzte Karte steht unter Hilfe › Info und in der Statuszeile. Versuchen Sie Automatisch, oder setzen Sie WGPU_ADAPTER_NAME auf einen eindeutigen Teil des Kartennamens.", "prefs.graphics_note": "Wägen Sie Leistung und Darstellungsqualität gegeneinander ab. Jede Option veranschaulicht ihre Wirkung anhand eines Beispiels.", "prefs.graphics_presets_note": "Mit den Profilen werden die unten aufgeführten Optionen gemeinsam eingestellt.", "prefs.hide_constraint_glyphs_zoomed_out": "Bindungssymbole bei starkem Herauszoomen ausblenden", @@ -1920,6 +2180,8 @@ "prefs.history_sketches_note": "Wenn „Skizzen“ auf „Unter dem zugehörigen Feature“ eingestellt ist, wird jede Skizze im linken Verlauf eine Ebene unter dem ersten daraus erstellten Feature angezeigt, sofern der Bereich breit genug für die Namen ist. Die Timeline unten zeigt weiterhin die Reihenfolge, in der die Elemente erstellt wurden.", "prefs.history_sketches_under_feature": "Unter dem zugehörigen Feature", "prefs.hold_stop_snapping": "Gedrückt halten, um das Einrasten zu deaktivieren", + "prefs.home_fit_size": "Größe bei Home und Einpassen", + "prefs.home_fit_size_note": "Wie viel der Ansicht das Modell füllt, wenn Home, Einpassen, ein geöffnetes Design oder ein Ansichtswechsel am Navigator es einrahmt, unabhängig von der Einstellung darunter. 100 %: füllt die Ansicht zwischen den Leisten, dem Kompass und der Navigationsleiste.", "prefs.icons_only": "Nur Symbole", "prefs.imported_file": "Importierte Datei", "prefs.in_browser": "Im Browser", @@ -1928,13 +2190,18 @@ "prefs.interface_language": "Oberflächensprache", "prefs.interface_note": "Legt das Erscheinungsbild von SolidCraft und die Anordnung seiner Bereiche fest.", "prefs.interval_minutes": "{n} min", + "prefs.keep_work_on_tab_switch": "Dialoge und Werkzeuge beim Wechsel zwischen Konstruktionen offen lassen", + "prefs.keep_work_on_tab_switch_note": "Jeder Konstruktions-Tab behält seinen offenen Dialog oder sein Werkzeug mit allem, was darin ausgewählt und eingegeben wurde, sowie seinen Werkzeugleisten-Tab. Beim Zurückkehren zur Konstruktion ist alles wieder da. Aus: Ein Wechsel bricht sie ab und zeigt den Tab Modell.", "prefs.labels": "Beschriftungen", + "prefs.language_system": "System", "prefs.layout": "Layout", "prefs.layout_note": "Im Layout „Kompakt“ werden Bereiche an den Fensterrändern angedockt. Transparenz für den Browser ist nur im Layout „Schwebend“ verfügbar. Die Position des Assistenten ändern Sie auf der Seite „Assistent“.", "prefs.left": "Links", "prefs.left_click": "Linksklick", "prefs.line_drag_draws_one": "Drücken und Ziehen zeichnet eine Linie", "prefs.line_drag_draws_one_note": "Im Linienwerkzeug zeichnet Drücken, Ziehen und Loslassen eine Linie vom Drückpunkt bis zum Loslassen, und dort endet sie. Klicks zeichnen weiterhin eine Kette. Aus: Ein Ziehen setzt wie ein Klick einen Punkt dort, wo losgelassen wird.", + "prefs.line_hover_tangent_arc": "Auf dem letzten Linienpunkt verweilen, um zum Tangentialbogen zu wechseln", + "prefs.line_hover_tangent_arc_note": "Im Linienwerkzeug: Nach einem Segment vom letzten Punkt der Kette wegbewegen, zurückkehren und den Zeiger still darauf halten. Der nächste Klick beendet dann einen Tangentialbogen, und dieselbe Geste wechselt zurück zu Linien. Aus: Das Verweilen bewirkt nichts.", "prefs.linked_designs": "Verknüpfte Designs", "prefs.live_input": "Live-Eingabe", "prefs.live_preview": "Live-Vorschau", @@ -1958,6 +2225,8 @@ "prefs.mouse_mapping": "Mausbelegung", "prefs.navigation": "Navigation", "prefs.navigation_note": "Legt fest, wie Maus, Trackpad und 3D-Maus die Ansicht steuern.", + "prefs.navigator_fit": "Modell nach einem Ansichtswechsel am Navigator einpassen", + "prefs.navigator_fit_note": "Eine Fläche, Ecke oder ein Pfeil des Würfels oder eine Achse des Gizmos blickt auch auf die Mitte des Modells, aus dem einen Abstand, der es von jeder Seite einpasst (seine Hüllkugel). Home nutzt denselben Zoom und dieselbe Mitte, solange dies an ist, sodass danach gewählte Ansichten sich nur drehen. Aus: Die Ansicht dreht sich an Ort und Stelle und behält Ziel und Zoom.", "prefs.network_host_remove": "Entfernen", "prefs.network_locations": "Netzwerkspeicherorte in Designs zulassen", "prefs.network_locations_note": "Deaktiviert: Verknüpfte Dateien eines Designs auf einem anderen Computer (\\\\server\\share) werden nur von den hier aufgeführten Servern geöffnet oder nachdem Sie einen Server auf Nachfrage ausdrücklich zugelassen haben.", @@ -1970,6 +2239,8 @@ "prefs.origin_plane_names": "Namen der Ursprungsebenen", "prefs.origin_plane_names_note": "Namen in Browser, Dialogen und Listen. {top} ist XY, {front} ist XZ und {right} ist YZ, mit Z nach oben. Befehle, Skripte und gespeicherte Konstruktionen behalten XY, XZ und YZ.", "prefs.overall_speed": "Gesamtgeschwindigkeit", + "prefs.parameter_list_rows": "Zeilen der Parameterliste", + "prefs.parameter_list_rows_tip": "Der Pfeil nach unten in einem leeren Wertefeld listet die Parameter auf, Favoriten zuerst, bis zu dieser Anzahl. Weniger hält die Liste in einem großen Design übersichtlich.", "prefs.persistent_ribbon_pressed": "Aktive Menübandbefehle hervorheben", "prefs.persistent_ribbon_pressed_help": "Hebt Befehle im Menüband hervor, solange ihre Effekte eingeschaltet sind. Deaktivieren Sie diese Option, um die bisherige neutrale Darstellung und Befehlshilfe beizubehalten.", "prefs.perspective_fov": "Sichtfeld", @@ -2004,6 +2275,8 @@ "prefs.render_previous_geometry_budget": "Bisheriges Geometriebudget für die Render-Vorschau verwenden", "prefs.render_previous_geometry_budget_help": "Stellt die bisherige feste Obergrenze für die Geometrie wieder her (512 MiB auf 64-Bit-Desktop-Systemen, andernfalls 128 MiB) anstelle des geräteabhängigen Budgets.", "prefs.reports_saved": "{n} gespeichert", + "prefs.ribbon_click_ends_tool": "Klick auf die Schaltfläche des laufenden Werkzeugs beendet es", + "prefs.ribbon_click_ends_tool_note": "Ein Klick auf die hervorgehobene Schaltfläche des aktiven Skizzenwerkzeugs beendet das Werkzeug, sodass Klicks in die Ansicht wieder auswählen. Aus: Der Klick startet das Werkzeug neu.", "prefs.ribbon_reveal": "Suchergebnisse im Menüband zeigen", "prefs.ribbon_reveal_note": "Steht der Zeiger über einem Ergebnis in Befehle suchen, zeigt das Menüband, wo der Befehl liegt: seine Registerkarte, seine Schaltfläche oder seine Zeile im Aufklappmenü eines Panels. Ein aus der Suche gestarteter Befehl blinkt dort, und ein laufender Befehl aus einem Aufklappmenü markiert sein Panel.", "prefs.right_click": "Rechtsklick", @@ -2046,6 +2319,8 @@ "prefs.sketch_tab_always_note": "Die Registerkarte Skizze bleibt im Menüband direkt nach Modell, auch wenn keine Skizze offen ist. Ihre Zeichenwerkzeuge beginnen dann eine Skizze auf der ausgewählten Ebene oder Fläche oder auf der Ebene, die Sie als Nächstes wählen, und das Werkzeug ist darin bereit.", "prefs.sketch_tracking_guides": "Ausrichtungshilfen für Mittelpunkte", "prefs.sketch_tracking_guides_note": "Den Mauszeiger über einen Kreismittelpunkt oder eine Kantenmitte bewegen und dann waagerecht oder senkrecht bewegen, um eine temporäre gepunktete Hilfslinie anzuzeigen.", + "prefs.sketch_tracking_points": "Ausrichtungshilfen von Endpunkten und Ecken beim Zeichnen", + "prefs.sketch_tracking_points_note": "Während ein Zeichenwerkzeug auf einen Punkt wartet, zeigt das Überfahren eines Linien- oder Bogenendes, einer Rechteckecke oder eines freien Punkts dieselbe temporäre gepunktete Hilfslinie wie ein Mittelpunkt. Aus: nur Mittelpunkte.", "prefs.sketching": "Skizzieren", "prefs.snap_drawing_note": "Gilt beim Zeichnen oder Verschieben eines Skizzenpunkts.", "prefs.source": "Quelle", @@ -2062,6 +2337,24 @@ "prefs.status_error": "Fehler", "prefs.status_selected": "Ausgewählt", "prefs.status_warning": "Warnung", + "prefs.structure.appearance": "Erscheinungsbild", + "prefs.structure.appearance_note": "Farben, Text und Barrierefreiheit.", + "prefs.structure.in_profile": "Im Profil", + "prefs.structure.layout": "Layout", + "prefs.structure.layout_note": "Anordnung der Fensterbereiche.", + "prefs.structure.look": "Ansicht", + "prefs.structure.measure_pointer": "Messen und Zeiger", + "prefs.structure.mouse3d": "3D-Maus", + "prefs.structure.mouse3d_note": "SpaceMouse und SpaceNavigator.", + "prefs.structure.navigation_note": "Maus und Trackpad. Die 3D-Maus hat eine eigene Seite.", + "prefs.structure.profile": "Profil", + "prefs.structure.profile_edited": "Seit dem Speichern geändert", + "prefs.structure.profile_hover": "Wird in Einstellungsprofilen gespeichert", + "prefs.structure.scale_percent": "{percent} %", + "prefs.structure.sketch": "Skizze", + "prefs.structure.tools": "Werkzeuge", + "prefs.structure.tools_note": "Verhalten der Modellierungswerkzeuge.", + "prefs.structure.work": "Arbeit", "prefs.supersampling_brief": "Rendert die Ansicht größer und verkleinert sie dann: sehr scharf, aber rechenintensiver.", "prefs.tab_accent_note": "Die Akzentfarbe richtet sich nach dem aktiven Tab.", "prefs.theme_file": "Designdatei", @@ -2073,6 +2366,16 @@ "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_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", + "prefs.theme_option_tab_colours_help": "Panel- und Ansichtsfarben dieses Designs je Registerkarte verwenden.", + "prefs.theme_option_tab_fade_ms": "Tabübergang", + "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_reset": "Zurücksetzen", + "prefs.theme_options_unavailable": "Auf diesem Gerät nicht verfügbar", "prefs.theme_paste": "Design einfügen", "prefs.theme_paste_none": "Die Zwischenablage enthält kein SolidCraft-Design", "prefs.timeline_half_hour": "−30 min", @@ -2107,6 +2410,8 @@ "prefs.viewport_background_theme": "Verlauf des Designs", "prefs.viewport_section_status": "Schnittstatus im Ansichtsfenster anzeigen", "prefs.viewport_section_status_help": "Die Schnitteinstellung im Ansichtsfenster anzeigen. Escape und die aktive Schaltfläche schalten den Schnitt aus. Deaktivieren, um die bisherigen Schnittbedienelemente zu behalten.", + "prefs.viewport_status_details": "Live-Details im Ansichtsfenster", + "prefs.viewport_status_details_help": "Aktive Analyse- und Schnitteinstellungen in standardisierten Detailkarten bearbeiten. Deaktivieren, um schreibgeschützte Analyselegenden und den Schnittdialog wiederherzustellen.", "prefs.viewport_status_summary": "Status im Ansichtsfenster zusammenfassen", "prefs.viewport_status_summary_help": "Drei oder mehr Statuselemente in einer Zusammenfassung anzeigen. Deaktivieren, um jedes Element in der vollständigen Zeile zu behalten.", "prefs.wheel": "Mausrad", @@ -2890,14 +3195,73 @@ "render.look_strength": "Look-Stärke", "render.look_teal_orange": "Türkis / Orange", "render.look_title": "Stapel kreativer Looks", + "render.look_warm": "Warm", + "render.mapping_scale": "Physischer Wiederholungsfaktor", + "render.material_absorption": "Absorption R · G · B (pro mm)", + "render.material_add": "Diesen Körper hinzufügen oder bearbeiten", + "render.material_assignments": "Körperzuweisungen", + "render.material_binding_enable": "Diese Körperüberschreibung einbeziehen", + "render.material_box": "Achsparalleler Quader", + "render.material_browse": "Bild wählen…", + "render.material_certification": "Geschlossenes Glas verwendet geometrische Abstände. Topologie, Ausrichtung, Überlappung und optische Grenzflächen werden bei der GPU-Vorbereitung geprüft; „Prüfen“ lässt die Anfrage lediglich zu.", + "render.material_choose_body": "Szenenkörper wählen", + "render.material_closed": "Geschlossenes Körpernetz", + "render.material_coat": "Klarlack über dem Grundmaterial", + "render.material_coat_enable": "Klarlack aktivieren", + "render.material_coat_help": "Illustrative dielektrische Beschichtung über dem vorhandenen undurchsichtigen Erscheinungsbild. Gewicht null lässt das Grundmaterial unverändert.", + "render.material_color_map": "Grundfarbentextur (sRGB)", "render.material_color_replace": "Basismap ersetzt Körperfarbe", + "render.material_custom": "Eigene illustrative Optik", + "render.material_enable": "Diese GPU-Materialüberschreibungen verwenden", + "render.material_filter": "Szenenkörper filtern…", + "render.material_filter_help": "Es werden bis zu 128 passende Körper angezeigt. Verfeinern Sie den Filter bei großen Szenen.", + "render.material_glass": "Glas und optische Absorption", + "render.material_glass_enable": "Physikalisches Glas für diesen Körper verwenden", + "render.material_glass_exclusive": "Glas kann in diesem Renderer nicht zusammen mit Klarlack oder Laufzeittexturen auf einem Körper verwendet werden.", + "render.material_glass_help": "Eigene illustrative Rezepte. Farbfelder zeigen die Transmission, keine gerenderte Vorschau. Die Absorption ist −ln(lineare Transmission) / Referenzabstand; Alpha bleibt unverändert.", + "render.material_gpu_required": "Diese aktiven Materialüberschreibungen erfordern GPU-Pathtracing. Wählen Sie GPU oder deaktivieren Sie die Überschreibungen, bevor Sie „Prüfen“ oder „Start“ verwenden.", + "render.material_ior": "Brechungsindex (IOR)", "render.material_limits": "Höchstens 128 Körperüberschreibungen, 32 getrennte Glaskörper, 64 Beschichtungen und 128 Texturbindungen. Identische Maps teilen Speicher; Geräte- und Gesamtspeichergrenzen bleiben aktiv.", + "render.material_map_invalid": "Texturpfade müssen begrenzte lokale Pfade sein; die Normalenstärke muss endlich sein und zwischen 0 und 1 liegen.", + "render.material_map_required": "Wählen Sie ein Farbbild oder ein Normalenbild, bevor Sie Texturen verwenden.", + "render.material_maps": "Planare Farb- und Normalentexturen", + "render.material_maps_enable": "Texturen für diesen undurchsichtigen Körper aktivieren", + "render.material_maps_help": "PNG/JPEG bis 1024×1024; insgesamt 16 MiB codiert. U und V sind orthogonale Wiederholungsvektoren in Welt-mm (höchstens 128 Wiederholungen). Die Farbe ist sRGB; Normalendaten verwenden OpenGL +Y. Die Dateien werden bei „Prüfen“/„Start“ erfasst.", "render.material_metallic_map": "Metallanteil-Map (linear)", + "render.material_normal_map": "Normalenbild (OpenGL, +Y)", + "render.material_normal_strength": "Normalenstärke (0–1)", + "render.material_origin": "Texturursprung X · Y · Z (mm)", + "render.material_refresh": "Szenenkörper aktualisieren", + "render.material_refresh_needed": "Die Szene hat sich geändert. Aktualisieren Sie die Körperliste, bevor Sie ein Ziel hinzufügen.", + "render.material_remove": "Diese Zuweisung entfernen", + "render.material_repeat_u": "Eine U-Wiederholung X · Y · Z (mm)", + "render.material_repeat_v": "Eine V-Wiederholung X · Y · Z (mm)", + "render.material_revalidate": "Diesen Körper erneut prüfen", + "render.material_revalidate_needed": "Diese Zuweisung gehört zu einer früheren Szenenrevision. Prüfen Sie die genaue Körperidentität erneut, bevor Sie rendern.", + "render.material_roughness": "Rauheit des Klarlacks (0,045–1)", "render.material_roughness_map": "Rauheitsmap (linear)", + "render.material_shadow": "Gerade Schattenstrahlen durchlassen", + "render.material_target_missing": "Ordnungsnummer und Name des Körpers stimmen nicht mehr überein. Entfernen Sie diese Zuweisung und wählen Sie den gewünschten Körper ausdrücklich erneut.", + "render.material_thickness": "Dicke der dünnen Schicht (mm)", + "render.material_thin": "Dünne Schicht", + "render.material_title": "GPU-Bildmaterialien", + "render.material_transient": "Vorübergehende optische Überschreibungen für diesen Dokument-Tab. Sie werden weder im Design noch im Render-Setup oder in der Materialbibliothek gespeichert.", "render.material_v_up": "Textur-V zeigt im Bild nach oben", + "render.material_weight": "Klarlack-Gewicht (0–1)", + "render.method_cpu": "CPU-Raytracing", + "render.method_gpu": "GPU-Pathtracing", + "render.method_limit_raster": "Die Rasterung verwendet die feste undurchsichtige Studiovorschau. Wählen Sie ausdrücklich eine Raytracing-Methode für die unterstützten optischen Einstellungen.", + "render.method_raster": "CPU-Rasterung", "render.navigation_help": "Die Kamera bewegt sich wie im Konstruieren (Einstellungen › Navigation). Setup speichern übernimmt die aktuelle Kamera.", + "render.nested_enable": "Zertifizierte verschachtelte Dielektrika", + "render.nested_help": "Bis zu 16 umschließende Schichten; jedes übergeordnete Element muss konvex und jedes untergeordnete strikt darin enthalten sein. Überschneidungen, sich berührende Flächen, dünne Schichten und die Transmission gerader Schattenstrahlen werden abgelehnt. Der Zustand „Kamera im Inneren“ folgt der zertifizierten Hierarchie. Erzeugt keine Flüssigkeitsoberfläche und verschmilzt keine sich berührenden CAD-Grenzflächen.", "render.opaque_image_summary": "Vorschau-PNG · opakes PBR · Transparenz durch Blending", "render.opaque_preview_help": "Opakes PBR und einfache Farben; Transparenz durch Blending. Brechung, HDRI und finales Rendering sind noch nicht verfügbar.", + "render.optics_remove": "Gespeicherte Optik entfernen", + "render.optics_replace": "Gespeicherte Optik dieses Ziels ausdrücklich ersetzen", + "render.optics_save": "Diese Optik im Setup speichern", + "render.optics_saved": "Gespeicherte optische Ziele", + "render.optics_saved_help": "Die genaue Identität von Definition, Feature und Instanz bleibt beim Wiederöffnen ohne Verbindung erhalten. Fehlende, ersetzte oder ausgeblendete Ziele werden abgelehnt; technische Materialien bleiben unverändert. Speichern oder verwerfen Sie numerische Änderungen, bevor Sie ein gespeichertes Ziel entfernen.", "render.outer_half_angle": "Äußerer Halbwinkel", "render.output_advanced": "Erweitert", "render.output_backend_ready": "Bereit", @@ -2919,17 +3283,32 @@ "render.output_grading": "Farbkorrektur", "render.output_image": "Bild", "render.output_largest": "{width} × {height} max.", + "render.output_lock_ratio": "Seitenverhältnis sperren", + "render.output_over_budget": "Das angeforderte Bild überschreitet den gewählten Ressourcenrahmen. Wählen Sie ausdrücklich eine passende Größe oder ein passendes Profil; es erfolgt keine automatische Herabstufung.", "render.output_presets_help": "Format, Qualität und Prüfliste befinden sich im Ausgabeeditor.", "render.output_quality": "Qualität", "render.output_quality_waiting": "Qualitätsdefinition steht noch aus", + "render.output_ratio_refused": "Die verknüpfte Abmessung überschreitet das Größenlimit dieser Methode. Die vorherige Größe bleibt erhalten; entsperren Sie das Verhältnis oder wählen Sie einen kleineren Wert.", "render.output_ready": "Bereit zum Rendern", "render.output_run_check": "Prüfung starten", "render.output_size_limit": "{method}: bis zu {dimension} px pro Seite und {pixels} Pixel. Größte passende Größe: {width} × {height}.", "render.output_size_method_profile": "Für {width} × {height} {method} mit dem CPU-Ressourcenprofil {profile} wählen oder eine kleinere Größe.", "render.output_size_ok": "Größe OK · {width} × {height} px", "render.output_size_profile": "Für {width} × {height} das CPU-Ressourcenprofil {profile} wählen oder eine kleinere Größe.", + "render.output_swap_orientation": "Breite und Höhe tauschen", "render.output_tools_inspector": "Größe und Format im Ausgabe-Inspektor bearbeiten.", "render.output_view_image": "Bild anzeigen", + "render.page_aging": "Alterung und Verschmutzung", + "render.page_all": "Alle Einstellungen", + "render.page_appearance": "Erscheinungsbild", + "render.page_background": "Hintergründe", + "render.page_camera": "Kamera", + "render.page_diagnostics": "Diagnose", + "render.page_effects": "Objektiv- und Nachbearbeitungseffekte", + "render.page_environment": "Umgebung", + "render.page_lighting": "Beleuchtung", + "render.page_output": "Ausgabe", + "render.page_scene": "Szene", "render.paint_label": "Aufmalen", "render.painting_with": "Malen mit {name}", "render.passive_stage": "Render-only stage", @@ -2937,7 +3316,10 @@ "render.perspective": "Perspektivische Kamera", "render.physical_acknowledge": "I understand this partial reference needs grade, condition and source review", "render.physical_associations": "Physical candidates and roles", + "render.physical_background_help": "Echter passiver Boden und echte Wand, getrennt vom Kamerahintergrund. Der Rundhorizont besteht aus 24 polygonalen Streifen; er ist keine unendliche oder analytisch glatte Fläche. Ersetzt die gespeicherte Bühne nur mit Ihrer Zustimmung.", "render.physical_copy_plan": "Copy plan with sources and omissions", + "render.physical_cyclorama": "Polygonalen Rundhorizont anpassen", + "render.physical_floor_wall": "Physischen Boden und Wand anpassen", "render.physical_material_name": "Proposed reference material name", "render.physical_omissions": "Properties not representable in the native card", "render.physical_plan_only": "Read-only plan prepared. No material was created or assigned. Keep its extended profile and omissions when reviewing the native card.", @@ -2952,40 +3334,81 @@ "render.pick_up_label": "Aufnehmen", "render.pick_up_prompt": "Ein Objekt anklicken, um seinen Look aufzunehmen", "render.pick_waiting": "Die Produktauswahl wird noch nicht unterstützt.", + "render.post_contrast": "Kontrast", + "render.post_gain_b": "Blauverstärkung", + "render.post_gain_g": "Grünverstärkung", + "render.post_gain_r": "Rotverstärkung", + "render.post_help": "Nur kreative Darstellung. RGB-Verstärkungen wirken vor dem Tone-Mapping; Kontrast, Sättigung und Vignettierung folgen im anzeigelinearen Licht. „Rendering starten“ erzeugt neue Transport-Samples. „Neu graden“ verwendet das gewählte behaltene lineare Rohbild ohne Entrauschen oder neue Strahlen.", + "render.post_reset": "Darstellungsfilter zurücksetzen", + "render.post_saturation": "Sättigung", + "render.post_title": "Darstellungsfilter", + "render.post_vignette": "Vignettierung", "render.preview": "Integrierte Vorschau", "render.preview_help": "Rasterdarstellung mit gemischter Transparenz. Brechung, HDRI und finales Rendering sind noch nicht verfügbar.", + "render.preview_image_settings_help": "Die Live-Vorschau behält eine feste Studiobeleuchtung. Belichtung und Bildbeleuchtung gelten für den Bildauftrag, wenn Sie „Prüfen“ oder „Start“ verwenden.", "render.preview_lighting_help": "Die Live-Vorschau zeigt nur direktes Licht und keine Schatten.", + "render.preview_retry": "Live-Vorschau erneut versuchen", + "render.preview_unavailable_help": "Die Live-Vorschau ist nicht verfügbar. Gespeicherte Setups und Bildauftragseinstellungen bleiben zugänglich. Ändern Sie die Szene oder versuchen Sie es ausdrücklich erneut; eine abgelehnte Szene wird nicht bei jedem Frame neu aufgebaut.", + "render.preview_upload": "Vertex-Daten der Live-Vorschau", + "render.preview_upload_help": "Genaue Vertex-Bytes der Vorschau, nicht der gesamte GPU- oder Prozessspeicher. Die Geometrie wird nicht vereinfacht. Bildaufträge verwenden eigene Zulassung und Beleuchtung.", + "render.progressive_image_summary": "Vorschau-PNG · PBR und Glas · progressive Samples", "render.property_conditions": "Bedingungen: {value}", "render.property_fallback": "Eigenschaft", "render.property_qualifier": "Qualifikator: {value}", "render.property_source": "Quelle: {id} - {locator}", "render.property_uncertainty": "Unsicherheit: {value}", "render.property_unknown": "unbekannt", + "render.quality_ack": "Die Projektion mit festem Budget und ihre Auslassungen bestätigen", + "render.quality_apply": "Dieses feste Sampling-Budget verwenden", "render.quality_balanced": "Ausgewogen", "render.quality_custom": "Benutzerdefiniert", "render.quality_draft": "Entwurf", "render.quality_high": "Hoch", + "render.quality_projection_help": "Q-Vorgaben enthalten künftige Anforderungen. Diese Aktion projiziert nur ein unterstütztes PNG-Budget mit festen Samples und lässt adaptives Stoppen, rohe lineare Ausgabe und unabhängige Transmissionslimits ausdrücklich aus.", + "render.quality_review": "Sampling-Budget prüfen", + "render.queue_pending": "In dieser Oberfläche gibt es keine automatische Produktionswarteschlange und keinen Animations-Renderer. Fertige Einzelbilder bleiben separat exportierbar.", "render.radial_assign": "Erscheinungsbild zuweisen", "render.radial_compare": "Vergleichen", + "render.radial_distortion": "Radiale Verzeichnung", "render.radial_fit": "Produkt einpassen", "render.radial_save": "Aufnahme speichern", "render.recent_looks": "Letzte Looks", + "render.regrade_action": "Lineares Rohbild neu graden (ohne Entrauschen)", + "render.regrade_help": "Erzeugt aus dem gewählten fertigen linearen Rohbild mit diesen Darstellungseinstellungen ein separates PNG. Es werden keine Strahlen verfolgt, Größe, Kamera und Lichter der Aufnahme nicht geändert und das EXR nicht überschrieben. Das Entrauschen wird nicht erneut angewendet: Rohbilder behalten dessen Führungsdaten nicht. Das abgeleitete Bild bleibt über dieselben Auftragssteuerungen exportierbar.", "render.regrade_label": "Neu abstimmen", "render.reload_settings": "Gespeicherte Einstellungen laden", "render.remove_override": "Überschreibung entfernen", "render.replace_all": "Alle ersetzen", + "render.replace_ground": "Ersetzen der vorhandenen Bühne bestätigen", "render.replace_label": "Ersetzen", "render.replace_saved_surface": "Replace an existing saved binding for this target", "render.replace_with": "Ersetzen durch", "render.restore_camera": "Gespeicherte Kamera laden", "render.result_actual": "100%", + "render.result_actual_help": "Ein Bildpixel pro Bildschirmpixel.", "render.result_actual_size": "100%", + "render.result_capture": "Erfasste Revision", + "render.result_current": "Die erfasste Modellrevision ist aktuell.", + "render.result_empty": "Noch kein fertiges Bild. Prüfen Sie die Einstellungen und starten Sie dann ein Rendering.", + "render.result_fit": "Bild einpassen", + "render.result_job": "Auftrag", + "render.result_navigation": "Verschieben und Zoomen folgen den Navigationseinstellungen. Leertaste + Ziehen verschiebt.", + "render.result_open": "Das fertige Bild in der Hauptansicht öffnen.", + "render.result_preview": "Live-Vorschau", + "render.result_provisional": "Vorläufiges CPU-Bild", + "render.result_provisional_help": "Dieser Zwischenstand ist nicht exportierbar. Der Export behält das letzte fertige Ergebnis; ein Abbruch kehrt zu diesem Ergebnis zurück.", "render.result_reduced_display": "Anzeigevorschau auf die Geräte-/Cachegrenze reduziert. PNG und Export behalten die volle Auflösung; native 100%-Ansicht ist hier nicht verfügbar.", + "render.result_retained": "Das zuletzt fertiggestellte Bild wird angezeigt; ein neueres Rendering hat es noch nicht ersetzt.", + "render.result_sequence": "Prüfpunkt", + "render.result_title": "Render-Ergebnis", "render.return_design": "Zurück zu Design", + "render.review_stale": "Diese Prüfung ist veraltet. Lösen Sie sie auf oder prüfen Sie erneut, bevor Sie sie anwenden.", + "render.ribbon_aging": "Alterung", "render.ribbon_backgrounds": "Hintergründe", "render.ribbon_check": "Prüfen", "render.ribbon_check_help": "Genau den aktuellen Renderentwurf prüfen. Kein stiller Rendererwechsel, keine Verkleinerung oder Ersetzung.", "render.ribbon_edit_lights": "Licht ändern", + "render.ribbon_effects": "Effekte", "render.ribbon_environment_help": "HDR-Beleuchtung, Kamerahintergründe und physische Bühnen sind getrennte Einstellungen mit ausdrücklicher Ersetzungsbestätigung.", "render.ribbon_finish_dialog": "Zuerst den Datei- oder Bestätigungsdialog abschließen.", "render.ribbon_frame": "Einpassen", @@ -3003,6 +3426,8 @@ "render.ribbon_studios": "Studios", "render.ribbon_studios_help": "Implementierte Studios durchsuchen und Ersetzungen vor dem Anwenden prüfen. Konzepte bleiben gekennzeichnet.", "render.ribbon_surface_maps": "Oberflächen", + "render.sampling_title": "Adaptives Sampling und Transmissions-Sampling", + "render.save_continue": "Speichern und fortfahren", "render.save_setup": "Setup speichern", "render.save_shot": "Aufnahme speichern", "render.saved_maps_runtime_conflict": "Saved surface bindings cannot be mixed with temporary optical overrides or an unsaved HDR. Save the HDR or remove the temporary inputs explicitly.", @@ -3010,27 +3435,43 @@ "render.saved_surfaces": "Saved surface bindings", "render.saved_surfaces_help": "Pinned built-in maps follow the selected occurrence in local millimeters. A replaced or missing target is an error, never an automatic reassignment.", "render.scene_all_shots": "Alle Aufnahmen ({count})", + "render.scene_apply": "Geprüftes Studio anwenden", "render.scene_batch_reason": "Alle Aufnahmen benötigen die noch nicht verfügbare Renderwarteschlange.", "render.scene_check_now": "Jetzt prüfen", "render.scene_check_passed": "Prüfung bestanden · {notes} Hinweise", "render.scene_check_stale": "Prüfung veraltet · erneut prüfen", "render.scene_check_unchecked": "Noch nicht geprüft · 0 Hinweise", + "render.scene_consent": "Ersetzen von Beleuchtung, Umgebung, Belichtung und Bühne bestätigen", "render.scene_light_summary": "{environment} · {lights} Lichter", "render.scene_look_summary": "{looks} Looks · {overrides} Überschreibungen", "render.scene_open_check": "Prüfung öffnen", + "render.scene_preserved_fields": "Kamera, Sampling, Abmessungen, optische Bindungen, CAD-Geometrie und technische Materialien bleiben erhalten.", + "render.scene_preserves_design": "Beleuchtung und eine passive Bühne für das sichtbare Motiv zusammenstellen. Geometrie, technische Materialien und die Kamera bleiben getrennt.", "render.scene_ready": "Bereit zum Rendern", "render.scene_reminder": "Die Szene bewahrt den Entwurf. Geometrie, technische Eigenschaften und CAD werden in Render nie verändert.", "render.scene_reminders": "Hinweise", "render.scene_renderer_ready": "{renderer} bereit", + "render.scene_replaces": "Ersetzt", + "render.scene_review": "Auflösen und prüfen", + "render.scene_review_help": "Prüfen Sie die vollständige native Zusammenstellung, bevor Sie sie anwenden. Nicht zugeordnete erweiterte SCENE-Spezifikationen werden nicht ersetzt.", + "render.scene_scope_details": "Umfang der Zusammenstellung", "render.scene_stages": "Phasen", "render.search_appearances": "{count} Erscheinungsbilder durchsuchen", + "render.setup_legacy_environment": "Dieses ältere Setup verwendet eine veraltete Umgebungsdrehung oder -intensität. Diese Werte bleiben erhalten; wandeln Sie sie ausdrücklich um, bevor Sie einen vollständigen Bildauftrag speichern.", "render.setup_name": "Name des Setups", + "render.setup_numeric_help": "„Speichern“ hält die aktive Bildmethode und alle ihre numerischen Einstellungen fest. Erneutes Laden oder die Wahl eines anderen Setups ersetzt diesen Entwurf; stellen Sie die Kamera separat wieder her.", + "render.setup_offline_help": "Sie können diese Methode ohne ihren Renderer bearbeiten und speichern. „Prüfen“ und „Start“ erfordern einen verfügbaren Host.", + "render.setup_runtime_unsupported": "Dieser vollständige Auftrag kann noch nicht gespeichert werden: Er enthält Texturen oder Szenenkörper-Bindungen. Entfernen Sie diese temporären Eingaben, bevor Sie ein portables Setup speichern.", + "render.setup_save_missing": "Die gespeicherte Render-Konfiguration konnte nach Abschluss des Befehls nicht gefunden werden.", + "render.sharpening": "Detailschärfung", "render.shell_tab_appearance": "Aussehen", "render.shell_tab_camera": "Kamera", + "render.shell_tab_lighting": "BELEUCHTUNG", "render.shell_tab_scene": "SZENE", "render.shot_aperture": "Blende", "render.shot_distance": "Abstand", "render.shot_dof": "Schärfentiefe", + "render.shot_duplicate": "Gespeicherte Aufnahme duplizieren", "render.shot_edit_output": "In Ausgabe bearbeiten", "render.shot_exposure_reason": "Belichtung benötigt CPU-Strahlverfolgung oder GPU-Pfadverfolgung.", "render.shot_exposure_section": "Belichtung", @@ -3043,6 +3484,13 @@ "render.shot_lens_gpu_reason": "Schärfentiefe benötigt GPU-Pfadverfolgung.", "render.shot_lens_perspective_reason": "Schärfentiefe benötigt perspektivische Projektion.", "render.shot_look_selection": "Auswahl anvisieren", + "render.shot_manager": "Gespeicherte Aufnahmen", + "render.shot_new_name": "Neuer Aufnahmename", + "render.shot_remove": "Gespeicherte Aufnahme entfernen", + "render.shot_remove_help": "Nur dieses gespeicherte Setup entfernen. Quellgeometrie und fertige Bilder bleiben erhalten; die Aktion lässt sich rückgängig machen.", + "render.shot_remove_title": "Gespeicherte Aufnahme entfernen?", + "render.shot_rename": "Gespeicherte Aufnahme umbenennen", + "render.shot_saved_only": "Speichern Sie zuerst den aktuellen Entwurf. Duplizieren und Umbenennen behalten die genauen gespeicherten Optionen und Ressourcenverweise bei.", "render.shot_selection_reason": "Einen Körper oder eine Fläche als Ziel auswählen.", "render.shot_size": "Größe", "render.shot_target": "Ziel", @@ -3064,6 +3512,10 @@ "render.size_presets_pending": "Kommt mit dem Ausgabeeditor", "render.size_preview": "Vorschau", "render.size_square": "Quadrat", + "render.sky_ground": "Boden · lineares RGB", + "render.sky_horizon": "Horizont · lineares RGB", + "render.sky_zenith": "Zenit · lineares RGB", + "render.soft_focus": "Weichzeichnung", "render.spot_cone": "Spotkegel", "render.stage_frame": "Bildausschnitt", "render.stage_layout.appearance_note": "Zum Anwenden auf die Auswahl klicken.", @@ -3164,10 +3616,55 @@ "render.state_in_progress": "Bild in Bearbeitung", "render.state_model": "Modellvorschau", "render.strip_label": "Leiste", + "render.studio_add_area": "Flächenlicht hinzufügen", + "render.studio_ambient": "Konstante Umgebungsstrahldichte", + "render.studio_ambient_rgb": "Konstante Umgebungsstrahldichte (lineares RGB)", + "render.studio_aperture": "Blendenradius", + "render.studio_area": "Flächenlicht", + "render.studio_area_help": "Bis zu vier einseitige Rechtecke. Orthogonale Halbkanten U und V bestimmen die Größe; U × V zeigt zur beleuchteten Seite. Die Koordinaten sind Weltmillimeter.", + "render.studio_center": "Mittelpunkt X, Y, Z (mm)", + "render.studio_denoise": "Rauschen im fertigen Bild reduzieren", + "render.studio_denoise_help": "Abschließender kantenerhaltender Filter; feine Details können weicher werden. Glas und scharfe spiegelnde Oberflächen umgehen den Filter.", + "render.studio_direction": "Richtung zur Sonne (X, Y, Z)", + "render.studio_environment": "Umgebung", + "render.studio_environment_bounds": "Die Umgebungsdrehung muss endlich und innerhalb von ±1.000.000 Radiant liegen, die Intensität zwischen 0 und 100.", + "render.studio_environment_none": "Keine Umgebungskarte", + "render.studio_focus": "Axialer Fokusabstand", + "render.studio_half_u": "Halbkante U (mm)", + "render.studio_half_v": "Halbkante V (mm)", + "render.studio_hdr": "Radiance-HDR-Datei", + "render.studio_hdr_browse": "HDR wählen…", + "render.studio_hdr_help": "Radiance-.hdr-Datei, 2:1-Panorama, bis zu 2048 × 1024. EXR ist nicht verfügbar. Die Farbannahme gilt nur, wenn keine Primärfarben angegeben sind.", + "render.studio_hdr_required": "Wählen Sie eine lokale .hdr-Datei.", + "render.studio_hdr_srgb": "Nicht angegebene Primärfarben als lineares sRGB interpretieren", + "render.studio_intensity": "Umgebungsintensität", + "render.studio_lens": "Schärfentiefe", + "render.studio_lens_perspective": "Die Schärfentiefe erfordert eine perspektivische Kamera.", + "render.studio_lighting": "Studiobeleuchtung", + "render.studio_lighting_enable": "Eigene Studiobeleuchtung verwenden", + "render.studio_needs_review": "Methode und Einstellungen prüfen", + "render.studio_radiance": "Strahldichte in linearem RGB", + "render.studio_ready": "Bereit zur Prüfung", + "render.studio_remove_area": "Flächenlicht entfernen", + "render.studio_rotation": "Umgebungsdrehung", + "render.studio_runtime_help": "Numerische Studioeinstellungen und geprüfte HDR-Quellen können mit dem Setup gespeichert werden. Optische bzw. Materialüberschreibungen von Körpern bleiben vorübergehend und ändern gespeicherte Erscheinungsbilder nicht.", + "render.studio_saved": "Gespeichertes Setup / aktuelle Kamera", + "render.studio_sky": "Künstlerischer Himmel", + "render.studio_sky_help": "Originaler Himmelsverlauf für die Beleuchtung; kein kalibriertes Atmosphärenmodell.", + "render.studio_sun": "Sonne", + "render.studio_sun_radius": "Winkelradius", "render.studio_tiles_require_gpu": "Studios benötigen GPU-Pfadverfolgung. Im Ausgabeeditor GPU wählen.", + "render.studio_title": "Render-Studio", + "render.studio_tone_map": "Tone-Mapping", "render.studios_environments": "Studios und Umgebungen", + "render.subsurface_enable": "Random Walk für innere Streuung", + "render.subsurface_help": "Nur für geschlossene Dielektrika. Streuung und RGB-Absorption sind getrennte Koeffizienten pro mm. Pfade streuen innerhalb der tatsächlichen Geometrie und werden dann an der Grenzfläche gebrochen oder reflektiert. Deaktivieren Sie die Transmission gerader Schattenstrahlen, das Entrauschen von Oberflächen und das äußere Volumen; verwenden Sie eine Fresnel-Schicht. Kein Diffusions-Shader, kein Dichtegitter und kein gemessenes Material. Dichte Medien können mehr Samples und Pfadtiefe benötigen.", + "render.subsurface_rate": "Streukollisionsrate", "render.tone_aces_fitted": "ACES angepasst", "render.tone_reinhard": "Reinhard", + "render.transmission_scope": "GPU-Glas unterstützt zertifizierte getrennte Medien. Unbeschränkt verschachtelte Medien und ein rauschfreier Transmissionsschätzer werden nicht bereitgestellt.", + "render.transmission_strata": "Bedingte Fresnel-Schichten", + "render.transmission_strata_help": "1 behält die ursprüngliche Zufallsfolge bei. 2 oder 4 Teilpfade stratifizieren die erste Wahl zwischen Reflexion und Transmission am Glas bei unveränderten Snell- und Absorptionsgesetzen. Mehr Pfade kosten mehr Rechenaufwand; das ist weder Entrauschen noch Unterstützung für verschachtelte Medien noch ein Versprechen schnelleren Renderns.", "render.tree_add_light": "Licht hinzufügen", "render.tree_add_shot": "Aufnahme hinzufügen", "render.tree_browser": "Render-Browser", @@ -3205,6 +3702,14 @@ "render.unavailable_preview": "Dieses Setup verlangt ein nicht verfügbares Vorschauprofil.", "render.unsaved": "Ungespeicherte Vorschauänderungen", "render.use_preview": "Integrierte Vorschau verwenden", + "render.volume_albedo": "Streualbedo · lineares RGB", + "render.volume_anisotropy": "Mittlerer Phasenkosinus", + "render.volume_enable": "Teilnehmendes Medium aktivieren", + "render.volume_extinction": "Extinktionskoeffizient", + "render.volume_help": "GPU-Transport innerhalb einer endlichen Box, mit Beer-Lambert-Abschwächung und HG-Streuung auf Kamera- und indirekten Pfaden. Verwendet ausdrückliche Studio-, HDR- und Himmelsbeleuchtung einschließlich Schattenabschwächung. Ein positiver Phasenkosinus streut nach vorn. Glasgrenzflächen und Oberflächenentrauschen müssen ausdrücklich deaktiviert werden. Extinktion null lässt den Transport unverändert. Keine physikalische Mondatmosphäre, kein Dichtegitter, keine Volumenemission, kein spektraler Transport und keine zertifizierte Materialeigenschaft.", + "render.volume_max": "Obere Grenzen · Weltkoordinaten (mm)", + "render.volume_min": "Untere Grenzen · Weltkoordinaten (mm)", + "render.volume_title": "Begrenztes homogenes Volumen", "render.wait_add_light": "Noch nicht unterstützt: benannte Lichtquellen erstellen", "render.wait_batch": "Noch nicht unterstützt: Renderwarteschlange gespeicherter Aufnahmen", "render.wait_compare": "Noch nicht unterstützt: Vergleich gespeicherter Bilder", @@ -3244,6 +3749,7 @@ "search.suggest": "Meinten Sie {label}?", "search.try": "Geben Sie einen Befehlsnamen, ein Merkmal in diesem Design oder einen Parameter ein. Um einen Befehl mit Werten auszuführen, geben Sie seine ID und ein JSON-Objekt ein: {example}", "search.valid": "Gültig", + "section.off_hint": "Keine Ebene gewählt: „OK“ schaltet den Schnitt aus.", "select a direction (an axis, a sketch line or a straight edge) first": "zuerst eine Richtung auswählen (eine Achse, eine Skizzenlinie oder eine gerade Kante)", "select a direction (an axis, a sketch line or a straight edge) for the profile to follow": "eine Richtung auswählen (eine Achse, eine Skizzenlinie oder eine gerade Kante), der das Profil folgen soll", "select a point on a component": "einen Punkt auf einer Komponente auswählen", @@ -3251,6 +3757,7 @@ "select an axis": "eine Achse auswählen", "select an axis, a straight or circular edge, or a cylinder, cone or torus face first": "zuerst eine Achse, eine gerade oder kreisförmige Kante oder eine Zylinder-, Kegel- oder Torusfläche auswählen", "select bodies first": "zuerst Körper auswählen", + "select components first": "zuerst Komponenten auswählen", "select corner points, or the two lines of one corner": "Eckpunkte oder die zwei Linien einer Ecke auswählen", "select one corner point, or the two lines of one corner": "einen Eckpunkt oder die zwei Linien einer Ecke auswählen", "select sheet edges first": "zuerst Blechkanten auswählen", @@ -3264,8 +3771,10 @@ "select the rim face first": "zuerst die Randfläche auswählen", "select the sheet edge first": "zuerst die Blechkante auswählen", "select two components": "zwei Komponenten auswählen", + "set by the sketch's lines": "durch die Skizzenlinien festgelegt", "settings.display_quality_note": "Feinere Stufen zeigen gekrümmte Flächen und Kanten glatter und brauchen länger zum Vernetzen. Nur die Ansicht ändert sich, nie das Design oder Exporte.", "settings.double_click_components": "Doppelklick auf eine Komponente aktiviert sie oder bearbeitet eine verknüpfte an Ort und Stelle", + "settings.frame_rate_details_note": "Fügt ein Diagramm der Bildzeiten der letzten Bilder hinzu, darüber die gezeichneten Dreiecke, dazu ein Histogramm der Bildzeiten und was die GPU gezeichnet hat. Ein Klick auf die Bildrate in der Ansicht blendet sie ein oder aus.", "settings.frame_rate_note": "Zeigt die Bilder pro Sekunde unten rechts in der Ansicht.", "settings.insert_lists_open_designs": "Konstruktion einfügen zeigt zuerst die in anderen Tabs geöffneten Konstruktionen", "settings.measure_axis_distances": "X/Y/Z-Maße anzeigen", @@ -3279,9 +3788,13 @@ "settings.reset_title": "Einstellungen wurden geändert", "settings.supersampling_note": "Zeichnet die Ansicht in höherer Auflösung und rechnet sie herunter: glattere Kanten, Linien und Raster auf Bildschirmen unter 4K. Kostet GPU-Zeit: etwa 2,25-mal so viele Pixel bei 1,5× und 4-mal bei 2×.", "settings.update_changed_links": "Geänderte verknüpfte Designs aktualisieren, wenn ein Tab wieder angezeigt wird", + "settings.window_system_details": "Details", + "settings.window_system_note_now": "Wird nach einem Neustart wirksam. Aktuell: {now}.", + "settings.window_system_wayland_limited": "Einige Funktionen sind unter nativem Wayland eingeschränkt.", "sheet.flange_type_base": "Basislasche", "sheet.flange_type_contour": "Konturlasche", "sheet.flange_type_edge": "Kantenlasche", + "sheet.no_body": "es gibt keinen Blechkörper: Beginnen Sie einen mit Flansch > Basislasche oder wandeln Sie eine Platte mit „In Blech umwandeln“ in Blech um (ihre große ebene Fläche wählen)", "sheet.personal_rules.confirm_delete": "Löschen bestätigen", "sheet.personal_rules.delete": "Löschen", "sheet.personal_rules.delete_prompt": "Möchten Sie diese persönliche Regel wirklich löschen? Regeln, die bereits in Entwürfen sind, bleiben unverändert.", @@ -3322,6 +3835,11 @@ "sketch_panel.author": "Autorenplatzierung: {path}", "sketch_panel.dimension_hint": "Einen Wert oder Ausdruck eingeben, dann OK drücken.", "sketch_panel.move_hint": "Abstände und einen Winkel eingeben, dann OK drücken.", + "sketch_panel.plane_angle": "{plane}, gedreht um {angle}", + "sketch_panel.plane_fixed": "Eine feste Ebene", + "sketch_panel.plane_flipped": "{plane}, von ihrer anderen Seite", + "sketch_panel.plane_offset": "{plane}, versetzt um {distance}", + "sketch_panel.plane_origin": "Die {plane}-Ebene", "sketch_panel.redefine_title": "{command}: {name}", "sketch_panel.sheet_attachment": "An eine ebene Fläche eines Blechkörpers angehängt; folgt diesem Bereich bei Änderungen am Blech.", "sketch_panel.text_hint": "Text und Platzierung bearbeiten, dann OK drücken.", @@ -3331,6 +3849,7 @@ "sketch_text.align_right": "Rechts", "sketch_text.bold": "Fett", "sketch_text.builtin_font": "{family} (integriert)", + "sketch_text.formula": "Mit = beginnen, um den Text aus Parametern aufzubauen: ='W' + number(Width)", "sketch_text.italic": "Kursiv", "sketch_text.loading_fonts": "Installierte Schriftarten werden gesucht…", "sketch_text.on_path": "Der Text folgt seiner Kurve: sie bestimmt seine Lage", @@ -3364,12 +3883,21 @@ "the two origins are on the same component": "die beiden Ursprünge liegen auf derselben Komponente", "thread.automatic_size": "Automatische Größe", "thread.designation": "Bezeichnung", + "thread.direction": "Gewinderichtung", "thread.iso_metric": "ISO metrisch", + "thread.left_hand": "Linksgewinde", + "thread.right_hand": "Rechtsgewinde", "thread.select_size": "Größe wählen", + "timeline.delete_after_marker_one": "1 Merkmal nach der Verlaufsmarkierung löschen", + "timeline.delete_after_marker_other": "{count} Merkmale nach der Verlaufsmarkierung löschen", + "timeline.delete_folders_tip": "Ihre Merkmale und verschachtelten Ordner im Verlauf behalten", "timeline.delete_folders_with_features_tip": "Löscht die Ordner mit allen Features darin und ihren Unterordnern", + "timeline.delete_many": "{count} ausgewählte Merkmale löschen", "timeline.edited_rolled_back": "{name} geändert, liegt aber hinter der Verlaufsmarke und wird daher nicht erzeugt: Marke dahinter ziehen, um das Ergebnis zu sehen", + "timeline.feature_error": "Fehler: {message}", "timeline.feature_hint": "Doppelklicken zum Bearbeiten · Ziehen zum Neuordnen · Rechtsklicken für weitere Optionen", "timeline.feature_rolled_back": "Zurückgesetzt: hinter der Verlaufsmarke, daher nicht erzeugt", + "timeline.feature_warning": "Warnung: {message}", "timeline.group": "Neuen Merkmalsordner anlegen", "timeline.move_many": "Zeitleistenelemente verschieben", "timeline.move_to_group": "Merkmale in Ordner verschieben", @@ -3519,6 +4047,8 @@ "ui.agent_chat_allowed": "Erlaubt", "ui.agent_chat_answered": "Antwort: {option}", "ui.agent_chat_approval": "Darf der Agent das tun?", + "ui.agent_chat_attach_selection": "Auswahl anhängen", + "ui.agent_chat_attach_selection_tip": "Das im Modell Ausgewählte (Flächen, Kanten, Merkmale, Körper) mit der Nachricht senden, damit der Agent weiß, was Sie meinen", "ui.agent_chat_attach_view": "Ansicht anhängen", "ui.agent_chat_attach_view_tip": "Ein Bild der aktuellen Ansicht mit der Nachricht senden", "ui.agent_chat_attachable": "Bilder, PDFs, Text- und CAD-Dateien", @@ -3530,12 +4060,20 @@ "ui.agent_chat_auth_terminal": "Führen Sie dies in einem Terminal aus und klicken Sie dann erneut auf Verbinden:", "ui.agent_chat_auth_title": "Beim Agenten anmelden", "ui.agent_chat_cancel": "Abbrechen", + "ui.agent_chat_cannot_start": "Installiert, startet aber nicht: {error}", "ui.agent_chat_changes": "Änderungen: {n}, ein Rückgängig-Schritt", + "ui.agent_chat_changes_n": "{n} Änderungen", "ui.agent_chat_changes_of": "{n}/{limit} Änderungen", + "ui.agent_chat_choose_model": "Modell wählen", "ui.agent_chat_close": "Schließen", "ui.agent_chat_command": "Agentenbefehl", "ui.agent_chat_connect": "Verbinden", "ui.agent_chat_copy": "Kopieren", + "ui.agent_chat_ctx_bodies": "Körper", + "ui.agent_chat_ctx_elements": "Elemente", + "ui.agent_chat_ctx_features": "Funktionen", + "ui.agent_chat_ctx_more": "+{n} weitere", + "ui.agent_chat_ctx_other": "Sonstiges", "ui.agent_chat_custom_hint": "Jedes Programm, das das Agent Client Protocol über stdin und stdout spricht. Es läuft ohne Shell.", "ui.agent_chat_denied": "Abgelehnt", "ui.agent_chat_deny": "Ablehnen", @@ -3571,12 +4109,15 @@ "ui.agent_chat_import_failed": "{name}: Der Import ist fehlgeschlagen: {error}", "ui.agent_chat_intro": "Chatten Sie mit einem Coding-Agenten, den Sie schon nutzen, oder einem Modell-Endpunkt Ihrer Wahl. Er arbeitet über die Werkzeuge von SolidCraft am geöffneten Design: Seine Änderungen erscheinen sofort und lassen sich pro Nachricht in einem Schritt rückgängig machen. Was er ohne Nachfrage darf, legen Sie unter Einstellungen > KI fest.", "ui.agent_chat_key_in_env": "Schlüssel gesetzt", + "ui.agent_chat_last_missing": "Der zuletzt verwendete Agent ({agent}) ist nicht mehr verfügbar. Wählen Sie einen Agenten und klicken Sie dann auf „Erneut versuchen“.", "ui.agent_chat_left": "Links", "ui.agent_chat_limit": "Der Agent hat die Grenze von {limit} Änderungen für eine Nachricht erreicht. Senden Sie eine weitere Nachricht, damit er weitermacht.", + "ui.agent_chat_login_found": "Anmeldung gefunden", "ui.agent_chat_minimize": "Zu einer Leiste minimieren", "ui.agent_chat_mode_all": "Alles", "ui.agent_chat_mode_ask": "Fragen", "ui.agent_chat_mode_edits": "Ändern", + "ui.agent_chat_model_no_tools": "{model} kann die Werkzeuge von SolidCraft nicht verwenden und daher nur chatten. Wählen Sie ein Modell mit Werkzeugunterstützung.", "ui.agent_chat_model_tip": "Das vom Agenten verwendete Modell. Ihre Auswahl wird für diesen Agenten gespeichert.", "ui.agent_chat_new": "Neuer Chat", "ui.agent_chat_new_confirm": "Neuen Chat starten? Der aktuelle wird geleert.", @@ -3585,6 +4126,7 @@ "ui.agent_chat_no_control": "Der Endpunkt des Agenten-Chats ist nicht gestartet, daher kann der Agent das Design nicht erreichen.", "ui.agent_chat_no_images": "Dieser Agent nimmt keine Bilder an", "ui.agent_chat_no_picture": "In der Zwischenablage befindet sich kein Bild", + "ui.agent_chat_no_tool_call": "Das Modell hat die Werkzeuge von SolidCraft nicht verwendet, daher wurde das Design nicht geändert. Wählen Sie ein Modell, das Werkzeugaufrufe unterstützt; siehe „Lokale Modelle“ im Leitfaden zum Agenten-Chat.", "ui.agent_chat_none_found": "Kein Coding-Agent gefunden. Installieren Sie einen davon und klicken Sie dann auf Aktualisieren:", "ui.agent_chat_none_found_short": "Kein Agent gefunden", "ui.agent_chat_not_installed": "Nicht installiert", @@ -3594,23 +4136,30 @@ "ui.agent_chat_paste_picture": "Bild einfügen", "ui.agent_chat_permission": "Der Agent möchte ausführen: {title}", "ui.agent_chat_pick": "Agent", + "ui.agent_chat_pick_context": "Kontext wählen", + "ui.agent_chat_pick_context_tip": "Flächen, Kanten, Merkmale und Körper im Modell wählen, die der Agent ansehen soll, dann auf OK klicken", "ui.agent_chat_placeholder": "Bitten Sie den Agenten, das Design zu ändern…", "ui.agent_chat_placeholder_busy": "Nachricht einreihen, bis der Agent fertig ist (Strg+Eingabe: jetzt senden)", "ui.agent_chat_pop_out": "In eigenem Fenster öffnen, das Sie auf einen anderen Bildschirm verschieben können", "ui.agent_chat_previous_picture": "Vorheriges Bild", "ui.agent_chat_privacy": "Nichts geht irgendwohin außer an den Agenten, den Sie unter Ihrem eigenen Konto ausführen.", "ui.agent_chat_privacy_note": "Der Chat sendet von sich aus nichts irgendwohin: Der Agent, den Sie starten, spricht unter Ihrem eigenen Konto mit seinem eigenen Dienst. SolidCraft sieht und speichert nie einen Schlüssel oder ein Passwort.", + "ui.agent_chat_provider_models": "{count} Modelle", + "ui.agent_chat_provider_other": "Sonstiges", + "ui.agent_chat_provider_tip": "Der Anbieter, dessen Modelle die nächste Liste zeigt.", "ui.agent_chat_queue": "Einreihen", "ui.agent_chat_queue_full": "Zu viele Nachrichten warten: Entfernen Sie eine oder warten Sie auf den Agenten.", "ui.agent_chat_queued": "Eingereiht", "ui.agent_chat_queued_stopped": "Eingereiht (gestoppt, nicht gesendet)", "ui.agent_chat_refresh": "Aktualisieren", "ui.agent_chat_refused": "Für den Agenten nicht erlaubt: {reason}", + "ui.agent_chat_reinstall": "{agent} ist installiert ({program}), ist aber beim Prüfen durch SolidCraft nicht gestartet: {error}\nDie Installation ist vielleicht unvollständig oder für einen anderen Computertyp. Installieren Sie es erneut mit `{install}`, prüfen Sie, dass es im Terminal seine Version ausgibt, und klicken Sie dann auf Aktualisieren.", "ui.agent_chat_remove": "Aus der Warteschlange entfernen", "ui.agent_chat_remove_file": "Von dieser Nachricht entfernen", "ui.agent_chat_remove_imported": "Von dieser Nachricht entfernen; das Teil bleibt im Design (Rückgängig entfernt es)", "ui.agent_chat_reply_cut_off": "Die Antwort hat ihre Längengrenze erreicht, bevor sie fertig war, und ist daher vielleicht unvollständig. Bitten Sie den Agenten weiterzumachen oder kürzer zu antworten.", "ui.agent_chat_reply_declined": "Das Modell hat abgelehnt, auf diese Nachricht zu antworten.", + "ui.agent_chat_retry": "Erneut versuchen", "ui.agent_chat_right": "Rechts", "ui.agent_chat_save": "Speichern", "ui.agent_chat_send": "Senden", @@ -3623,6 +4172,7 @@ "ui.agent_chat_state_auth": "Anmeldung nötig", "ui.agent_chat_state_busy": "Arbeitet…", "ui.agent_chat_state_exited": "Agent beendet", + "ui.agent_chat_state_failed": "Verbindung fehlgeschlagen", "ui.agent_chat_state_off": "Nicht verbunden", "ui.agent_chat_state_ready": "Bereit", "ui.agent_chat_state_starting": "Startet…", @@ -3630,6 +4180,7 @@ "ui.agent_chat_stop_tip": "Den Agenten und alles, was er gestartet hat, beenden", "ui.agent_chat_stop_turn_tip": "Agenten stoppen (Esc). Eingereihte Nachrichten bleiben eingereiht.", "ui.agent_chat_turn_limit": "Änderungen pro Nachricht", + "ui.agent_chat_turn_unlimited": "Unbegrenzt", "ui.agent_chat_undo_changes": "Diese Änderungen rückgängig machen", "ui.agent_chat_undo_label": "Agenten-Chat: {message}", "ui.agent_chat_undone": "(rückgängig gemacht)", @@ -3637,11 +4188,15 @@ "ui.agent_chat_waiting": "Wartet auf Sie", "ui.agent_cursor": "Agent-Cursor", "ui.agent_cursor_speed": "Agent-Cursor-Geschwindigkeit", + "ui.agent_off_thread": "Fenster bedienbar halten, während Agenten Befehle ausführen", + "ui.agent_off_thread_note": "Langsame Befehle eines Agenten (aus dem Assistentenbereich oder von einem MCP-Client) werden neben dem Fenster berechnet: Sie können die Ansicht weiter drehen und prüfen und einen Befehl über den Chip in der Ansicht stoppen. Aus: Jeder läuft im Thread des Fensters, und das Fenster wartet darauf.", "ui.ai_add": "Hinzufügen", "ui.ai_add_agent": "Agent hinzufügen…", "ui.ai_add_agent_title": "Agent hinzufügen", + "ui.ai_add_anthropic": "Anthropic-kompatible API", "ui.ai_add_api": "OpenAI-kompatible API", "ui.ai_add_custom": "Eigener Befehl", + "ui.ai_add_endpoint": "API-Endpunkt", "ui.ai_add_installed": "Installierter Agent", "ui.ai_add_installed_note": "Ein eigener Eintrag für einen installierten Agenten, zum Beispiel mit anderen Argumenten. Installierte Agenten erscheinen von selbst.", "ui.ai_added": "hinzugefügt", @@ -3662,15 +4217,21 @@ "ui.ai_copy_suffix": "Kopie", "ui.ai_custom_url": "Eigene URL", "ui.ai_default": "Standard", + "ui.ai_edit": "Bearbeiten", + "ui.ai_edit_agent_title": "Agent bearbeiten", + "ui.ai_edit_gone": "Dieser Agent wurde entfernt, es gibt also nichts zu speichern.", + "ui.ai_edit_tip": "Name, Befehl oder Endpunkt dieses Agenten ändern. Ein gespeicherter Schlüssel bleibt verborgen.", "ui.ai_http_busy": "der Endpunkt ist ausgelastet oder am Limit; versuchen Sie es später", "ui.ai_http_key": "der Endpunkt hat den Schlüssel abgelehnt: prüfen Sie ihn unter Einstellungen > KI", "ui.ai_http_not_found": "Endpunkt oder Modell nicht gefunden: prüfen Sie Basis-URL und Modellnamen", "ui.ai_http_too_large": "die Unterhaltung ist für diesen Endpunkt zu groß; beginnen Sie einen neuen Chat", "ui.ai_key": "API-Schlüssel", "ui.ai_key_hint": "optional; im Schlüsselbund des Systems gespeichert", + "ui.ai_key_keep_hint": "leer lassen, um den gespeicherten Schlüssel zu behalten", "ui.ai_key_needs_https": "Ein Schlüssel wird nur über HTTPS oder an diesen Computer gesendet.", "ui.ai_key_note": "Im Schlüsselbund des Systems gespeichert, nie in den Einstellungen von SolidCraft. Für einen lokalen Server leer lassen.", "ui.ai_keychain_error": "Der Schlüsselbund des Systems hat abgelehnt: {error}", + "ui.ai_kind": "Art", "ui.ai_mode_all": "Alles erlauben", "ui.ai_mode_all_note": "Nichts fragt vorher. Absturzberichte bleiben tabu, und die Ordnergrenzen gelten weiter.", "ui.ai_mode_all_warning": "Der Agent kann Ihr Design ohne Nachfrage speichern, überschreiben, exportieren und ersetzen. Nutzen Sie das nur mit einem Agenten, dem Sie vertrauen, und mit gespeicherten Designs.", @@ -3692,8 +4253,13 @@ "ui.ai_panel": "Panel", "ui.ai_permissions": "Berechtigungen", "ui.ai_preset": "Vorlage", + "ui.ai_protocol": "Protokoll", "ui.ai_remove": "Entfernen", + "ui.ai_request_timed_out": "Das Modell hat länger als {seconds} s gebraucht. Sie können das Anfrage-Zeitlimit für diesen Endpunkt unter Einstellungen › Assistent erhöhen.", + "ui.ai_request_timeout": "Anfrage-Zeitlimit", + "ui.ai_request_timeout_hint": "Wie lange eine Modellanfrage an diesen Endpunkt dauern darf, von 30 s bis zu einer Stunde. Erhöhen Sie den Wert für ein langsames lokales Modell.", "ui.ai_reset": "Zurücksetzen", + "ui.ai_save": "Speichern", "ui.ai_settings": "KI-Einstellungen…", "ui.ai_show_features": "KI-Funktionen anzeigen", "ui.ai_show_features_note": "Der Agenten-Chat: ein Panel, in dem ein Coding-Agent, den Sie schon nutzen, oder ein Modell-Endpunkt Ihrer Wahl am geöffneten Design arbeitet. Aus: keine KI-Schaltfläche, kein Kürzel, kein Menüeintrag, kein Panel, und nichts wird gesucht oder gestartet.", @@ -3701,13 +4267,33 @@ "ui.ai_test": "Testen", "ui.ai_test_acp_ok": "Antwortet: {agent}", "ui.ai_test_acp_tip": "Startet den Agenten kurz und bittet um eine Sitzung; beendet ihn wieder", + "ui.ai_test_answered": "Erreicht: {model} hat geantwortet", + "ui.ai_test_anthropic_tip": "Sendet eine kurze Nachricht an den Endpunkt", "ui.ai_test_api_tip": "Fragt den Endpunkt nach seinen Modellen", "ui.ai_test_no_model": "Erreicht: {count} Modelle, aber nicht {model}", "ui.ai_test_ok": "Erreicht: {count} Modelle", "ui.ai_testing": "Teste…", + "ui.ai_tool_arguments": "Argumente", + "ui.ai_tool_details": "Details", "ui.ai_tool_done": "fertig", "ui.ai_tool_failed": "fehlgeschlagen", + "ui.ai_tool_result": "Ergebnis", "ui.ai_tool_running": "läuft", + "ui.ai_tool_sum_bodies_one": "{count} Körper", + "ui.ai_tool_sum_bodies_other": "{count} Körper", + "ui.ai_tool_sum_by": "um {value}", + "ui.ai_tool_sum_edges_one": "{count} Kante", + "ui.ai_tool_sum_edges_other": "{count} Kanten", + "ui.ai_tool_sum_faces_one": "{count} Fläche", + "ui.ai_tool_sum_faces_other": "{count} Flächen", + "ui.ai_tool_sum_features_one": "{count} Merkmal", + "ui.ai_tool_sum_features_other": "{count} Merkmale", + "ui.ai_tool_sum_measured": "Gemessen: {what}", + "ui.ai_tool_sum_more": "{steps} und {count} weitere", + "ui.ai_tool_sum_radius": "r {value}", + "ui.ai_tool_sum_saved": "{file} gespeichert", + "ui.ai_tool_sum_screenshot": "Screenshot", + "ui.ai_tool_sum_then": "{first}, dann {next}", "ui.ai_tool_unfold": "Klicken, um das Ergebnis zu zeigen", "ui.ai_tool_waiting": "wartet", "ui.ai_toolbar_button": "Schaltfläche in der Werkzeugleiste anzeigen", @@ -3814,6 +4400,8 @@ "ui.copy": "Kopieren", "ui.corner": "Ecke", "ui.corners": "Ecken", + "ui.cosmetic_thread_markings": "Kosmetische Gewindemarkierungen", + "ui.cosmetic_thread_markings_help": "Schematische Markierungen an zylindrischen Gewindewänden anzeigen. Ändert nur die Anzeige, nie das Design oder Exporte.", "ui.counterbore": "Tiefenbohrung", "ui.countersink": "Senkung", "ui.create": "Erstellen", @@ -3831,6 +4419,7 @@ "ui.dark": "Dunkel", "ui.default_modelling_orientation": "Standard-Modellierungsorientierung", "ui.delete": "Löschen", + "ui.delete_select_first": "Wählen Sie zuerst aus, was gelöscht werden soll: Features, Körper, Komponenten oder Skizzengeometrie", "ui.depth": "Tiefe", "ui.design": "Design", "ui.design_properties.author": "Autor", @@ -3845,6 +4434,40 @@ "ui.design_properties.project": "Projekt", "ui.design_properties.revision": "Revision", "ui.design_saved": "Gespeichert unter {path}", + "ui.details_access_caption": "Flächen zeigen, ob ein Werkzeug sie aus der gewählten Richtung erreichen kann.", + "ui.details_angle": "Winkel", + "ui.details_continuity_caption": "Ausgewählte Naht bearbeiten. Andere Nähte behalten ihre eigenen Einstellungen.", + "ui.details_continuity_stale": "Frühere Geometrie: Analyse erneut ausführen, um diese Naht zu bearbeiten oder in die Ansicht einzupassen.", + "ui.details_copy_list": "Liste kopieren", + "ui.details_curvature": "Krümmungstoleranz", + "ui.details_curvature_caption": "Flächen werden anhand ihres lokalen Krümmungsradius eingefärbt.", + "ui.details_direction": "Werkzeugrichtung", + "ui.details_draft_caption": "Flächen werden anhand ihres Winkels zur Entformungsrichtung eingefärbt.", + "ui.details_edit_seam": "Naht bearbeiten", + "ui.details_fixture": "UI-Bausteinbeispiel — nur Beispieldaten", + "ui.details_fixture_caption": "UI-Bausteinbeispiel mit veranschaulichenden Werten; dies ist kein Modellergebnis.", + "ui.details_fixture_problem": "Beispielbefund mit einem Problem", + "ui.details_fixture_readout": "Beispielmesswert", + "ui.details_flip": "Umkehren", + "ui.details_gap": "Spalt", + "ui.details_hatch": "Schnittflächen schraffieren", + "ui.details_jump": "Krümmungssprung", + "ui.details_key": "Legende", + "ui.details_offset": "Versatz", + "ui.details_plane": "Ebene", + "ui.details_pull": "Entformungsrichtung", + "ui.details_radius": "Radius", + "ui.details_readout": "Messwerte", + "ui.details_samples": "Abtastpunkte", + "ui.details_seam": "{body} · Kante {edge}", + "ui.details_settings": "Einstellungen", + "ui.details_sharp_gap": "Scharfe Kante oder Spalt", + "ui.details_show_all": "Alle {count} anzeigen", + "ui.details_smooth": "Glatt", + "ui.details_stripes": "Streifen", + "ui.details_target": "Geometrie in Ansicht einpassen", + "ui.details_worst_seams": "Schlechteste Nähte", + "ui.details_zebra_caption": "Reflektierte Streifen zeigen, wie glatt die Flächen ineinander übergehen.", "ui.diameter": "Durchmesser", "ui.dimension": "Maß", "ui.direct_interaction": "Direkte Bearbeitung", @@ -3889,6 +4512,7 @@ "ui.edit_in_place_saved_elsewhere": "Das Teil wurde in eine andere Datei gespeichert; die Baugruppe verknüpft weiterhin {name}", "ui.edit_in_place_wait": "Warten, bis abgeschlossen ist, was im Design läuft", "ui.edit_section_analysis": "Querschnittsanalyse bearbeiten", + "ui.edit_sketch_empty": "Keine Skizzen zum Bearbeiten", "ui.ellipse": "Ellipse", "ui.emboss_wrap_note": "Senkrechte Linienprofile, die einen unterstützten Zylinder berühren, werden automatisch aufgewickelt. Eine Wand auswählen, um ein Ziel festzulegen.", "ui.end_radius": "Endradius", @@ -3899,12 +4523,15 @@ "ui.equation_explicit": "Explizit", "ui.equation_form": "Form", "ui.equation_note": "Die Variable ist eine Zahl ohne Einheit: sin(t) liest sie im Bogenmaß. Ein Ergebnis ohne Einheit ist in der Einheit der Konstruktion.", + "ui.equation_note_range": "Die Variable ist eine Länge, wie ihr Bereich: Sie lässt sich mit Längenparametern kombinieren; für sin() durch eine Länge teilen. Ein Ergebnis ohne Einheit gilt in der Einheit der Konstruktion.", "ui.equation_parametric": "Parametrisch", + "ui.equation_variable_unit": "Variable hat die Einheit des Bereichs", "ui.equation_x_of_t": "x(t)", "ui.equation_y_of_t": "y(t)", "ui.equation_y_of_x": "y(x)", "ui.equivalent_radius": "Äquivalenter Radius", "ui.expand": "Aufklappen", + "ui.explode_needs_two_components": "Die Explosionsdarstellung zieht Komponenten auseinander, daher braucht die Konstruktion mindestens zwei davon (Neue Komponente, Konstruktion einfügen oder Komponenten aus Körpern erstellen)", "ui.exportDxf": "Als DXF exportieren…", "ui.export_ellipsis_keymap": "Exportieren…", "ui.export_keymap_tip": "Tasten, S-Box-Favoriten und die Symbolleiste als Datei", @@ -3914,6 +4541,8 @@ "ui.extract_design_changed": "Das Design hat sich beim Wählen einer Datei geändert. Starten Sie In Datei extrahieren erneut.", "ui.extract_done": "Die Komponente ist jetzt ein eigenes Design und hier verknüpft.", "ui.extrude": "Extrudieren", + "ui.extrude_auto_to_object": "Bis zu einem gewählten Ziel extrudieren", + "ui.extrude_auto_to_object_tip": "Wenn in „Extrudieren“ ein Profil gewählt ist, schaltet das Wählen einer Fläche, eines Eckpunkts oder einer parallelen Ebene außerhalb der Profilebene die Ausdehnung auf „Bis Objekt“ um", "ui.factor": "Faktor", "ui.feature_history": "Feature-Verlauf", "ui.feature_options": "Funktionsoptionen", @@ -3932,6 +4561,7 @@ "ui.flip": "Umkehren", "ui.format": "Format", "ui.frame_rate": "Bildrate", + "ui.frame_rate_details": "Bildratendetails", "ui.front": "Vorne", "ui.fully_constrained": "Vollständig bestimmt", "ui.function": "Funktion", @@ -4027,21 +4657,32 @@ "ui.hideOrigin": "Ursprung ausblenden", "ui.hidePlanes": "Ebenen ausblenden", "ui.hideProfile": "Profil ausblenden", + "ui.hide_origin_by_default": "Ursprung standardmäßig ausblenden", + "ui.hide_origin_note": "Neue Designs beginnen mit ausgeblendetem Ordner „Ursprung“. Ein Design behält seine eigene Ursprungseinstellung, sobald Sie ihn ein- oder ausblenden.", "ui.history_back": "Einen Schritt zurück", "ui.history_below_browser": "Unter dem Browser", + "ui.history_edge_hint": "Kante doppelklicken", "ui.history_first": "Zum Anfang", "ui.history_forward": "Einen Schritt vor", + "ui.history_hide_names": "Namen ausblenden", "ui.history_last": "Zum Ende", + "ui.history_marker_tip": "Verlaufsmarkierung: Ziehen setzt das Modell zurück; neue Merkmale kommen hierhin", + "ui.history_names": "Namen", + "ui.history_play": "Bis zum Ende abspielen", "ui.history_play_button_hide": "Wiedergabe-Schaltfläche ausblenden", "ui.history_play_button_show": "Wiedergabe-Schaltfläche einblenden", "ui.history_play_finish_action": "Die aktuelle Aktion abschließen, bevor der Verlauf abgespielt wird", "ui.history_play_roll_back": "Die Verlaufsmarkierung vor dem Abspielen zurücksetzen", + "ui.history_show_names": "Namen anzeigen", + "ui.history_sketch_listed_under": "Aufgeführt unter {feature}", "ui.history_title": "Verlauf", + "ui.history_toggle_names": "Merkmalsnamen ein- oder ausblenden", "ui.hole": "Bohrung", "ui.hole_tap_type": "Gewindetyp", "ui.hole_type": "Bohrungstyp", "ui.home": "Start", "ui.homeView": "Startansicht", + "ui.horizontal_constraint": "Horizontale Bedingung", "ui.horizontal_vertical": "Horizontal/Vertikal", "ui.import_ellipsis": "Importieren…", "ui.infinite": "Unendlich", @@ -4059,12 +4700,14 @@ "ui.joints": "Gelenke", "ui.keyboard_shortcuts": "Tastenkürzel", "ui.language": "Sprache", + "ui.lasso_select": "Lasso (freihändig umfahren: wählt, was ganz innen liegt)", "ui.layout_compact": "Kompakt", "ui.layout_floating": "Schwebend", "ui.length": "Länge", "ui.length_decimals": "Längen-Dezimalstellen", "ui.light": "Hell", "ui.line": "Linie", + "ui.line_arc_hint": "Tangentialbogen: Ende des Bogens anklicken. Für Linien erneut auf dem letzten Punkt verweilen; Rechtsklick oder Esc beendet", "ui.link_copies": "Identische Kopien verknüpfen", "ui.link_copies_keep": "Behalten", "ui.link_copies_link": "Verknüpfen", @@ -4110,6 +4753,11 @@ "ui.move": "Verschieben", "ui.multiple": "Mehrfach", "ui.named_views": "Benannte Ansichten", + "ui.nav_bridge": "Mit einer Geräte-App auf diesem Computer navigieren", + "ui.nav_bridge_connected": "Verbunden", + "ui.nav_bridge_port": "Port", + "ui.nav_bridge_tip": "Das eigene Programm eines 3D-Eingabegeräts (zum Beispiel der Treiber einer selbstgebauten 3D-Maus) bewegt die Ansicht über eine lokale Verbindung, sodass der Mauszeiger Ihnen bleibt. SolidCraft verbindet sich nur auf diesem Computer über diesen Port damit.", + "ui.nav_bridge_waiting": "Warten auf die Geräte-App", "ui.navigation": "Navigation", "ui.needs_design": "{command}: Öffnen Sie zuerst ein Design", "ui.newSketchGroup": "Neue Skizzengruppe", @@ -4148,7 +4796,13 @@ "ui.part_size_shared": "Baut das Teil neu auf und ersetzt alle hinzugefügten Merkmale. Alle Vorkommen dieser Komponente werden aktualisiert.", "ui.paste": "Einfügen", "ui.path": "Pfad", + "ui.pattern_object_type_tip": "Körper: feste Formen, in der Ansicht oder im Browser gewählt; jeder wird ganz kopiert.\nFunktionen: Schritte im Verlauf, etwa eine Extrusion oder eine Bohrung, im Verlauf oder auf einer von ihnen erzeugten Fläche gewählt; der Schritt wird wiederholt.\nEine Skizze ist keines von beiden: Ihre Kurven mit Muster oder Spiegeln in der Skizze wiederholen oder sie zuerst extrudieren.", "ui.pattern_on_path": "Pfadmuster", + "ui.perforate_margin_default": "aus dem Durchmesser", + "ui.perforate_pitch": "Teilung", + "ui.perforate_square": "Quadrat", + "ui.perforate_staggered": "Versetzt (60°)", + "ui.perforate_title": "PERFORIEREN", "ui.perpendicular": "Senkrecht", "ui.perspective_view": "Perspektivische Ansicht", "ui.pinToShortcuts": "An Verknüpfungen anheften", @@ -4156,12 +4810,16 @@ "ui.pipe": "Rohr", "ui.pitch": "Steigung", "ui.placement": "Platzierung", + "ui.placement_handles": "Platzierungsgriffe", + "ui.placement_handles_tip": "Griffe an der Vorschau einer Auflage, eines Ansatzes oder einer Rastverbindung: Ziehen Sie die Markierung, um sie über das Modell zu bewegen, und drehen Sie sie am Ring", "ui.plane": "Ebene", "ui.plane_at_angle": "Ebene im Winkel", "ui.plugins": "Plugins", "ui.plugins_desktop": "Plugins sind in der Desktop-App verfügbar.", "ui.plugins_empty": "Keine Plugins installiert.", + "ui.plugins_none": "Keine Plugins entsprechen dieser Suche.", "ui.plugins_note": "Jedes Plugin ist ein Ordner in {path} mit einer plugin.json. SolidCraft liest diese Datei und führt keinen Code aus dem Ordner aus. Das Ausschalten eines Plugins lässt es installiert.", + "ui.plugins_search": "Plugins suchen", "ui.plugins_skipped": "{count} Ordner konnte(n) nicht gelesen werden.", "ui.plugins_version": "{version}", "ui.point": "Punkt", @@ -4204,6 +4862,7 @@ "ui.properties.inertia_y": "Iyx, Iyy, Iyz", "ui.properties.inertia_z": "Izx, Izy, Izz", "ui.properties.measuring": "Wird gemessen…", + "ui.properties.measuring_bodies": "Wird gemessen… {done} von {total} Körpern", "ui.properties.measuring_failed": "Die Messung wurde vor dem Ende abgebrochen.", "ui.properties.principal_axis": "Hauptachse {number}", "ui.properties.principal_moments": "Hauptträgheitsmomente", @@ -4220,6 +4879,9 @@ "ui.rectangle": "Rechteck", "ui.rectangular_pattern": "Rechteckmuster", "ui.redo": "Wiederholen", + "ui.reference_1": "Referenz 1", + "ui.reference_2": "Referenz 2", + "ui.reference_distance": "Referenzabstand", "ui.relink_design_changed": "Die Baugruppe hat sich beim Wählen einer Quelle geändert. Starten Sie Quelle neu verknüpfen erneut.", "ui.relink_done": "Quelle für alle Instanzen neu verknüpft.", "ui.relink_not_linked": "Diese Komponente ist nicht mit einem Quelldesign verknüpft.", @@ -4255,6 +4917,7 @@ "ui.right": "Rechts", "ui.save": "Speichern", "ui.save_as": "Speichern unter", + "ui.save_mesh_no_slicer_open": "Kein Slicer gefunden: Wählen Sie „Benutzerdefiniert…“ und wählen Sie Ihr Slicer-Programm", "ui.save_mesh_pick_bodies": "Wähle die Körper zum Speichern aus", "ui.saved_to": "Gespeichert: {path}", "ui.sbox_pin": "Oben im S-Feld anheften", @@ -4273,7 +4936,9 @@ "ui.selection_shift_add_only": "Umschalt+Klick fügt hinzu, ohne zu entfernen", "ui.selection_shift_add_only_tip": "An: Umschalt fügt Elemente hinzu, Strg/Cmd schaltet um. Aus (Standard): Umschalt und Strg/Cmd schalten beide um.", "ui.send": "Senden", + "ui.send_to_slicer_unavailable": "„An Slicer senden“ benötigt die Desktop-App: Ein Browser kann kein Programm starten", "ui.sent_to_slicer": "An {slicer} gesendet", + "ui.sent_to_slicer_copy": "An {slicer} als {file} gesendet, weil die erste Datei dort noch geöffnet ist", "ui.sheet_gap": "Spalt", "ui.shell": "Hülle", "ui.shortcut_capture_add_key": "Eine normale Taste hinzufügen. Escape bricht ab.", @@ -4281,6 +4946,8 @@ "ui.shortcut_capture_press": "Modifikatortasten und eine Taste drücken. Escape bricht ab.", "ui.shortcut_capture_release": "Zum Bestätigen alle Tasten loslassen. Escape bricht ab.", "ui.shortcut_capture_settling": "Tastenkürzel wird bestätigt… Escape bricht ab.", + "ui.shortcut_cycles_modes": "Erneutes Drücken des Tastenkürzels eines Werkzeugs wechselt dessen Modus", + "ui.shortcut_cycles_modes_tip": "Vor dem ersten Punkt schaltet erneutes Drücken des Tastenkürzels eines Skizzenwerkzeugs durch dessen Erstellungsmodi. Aus: Es startet das Werkzeug erneut.", "ui.shortcut_multiple_keys": "Modifikatortasten und nur eine weitere Taste verwenden. Zum Wiederholen die Belegung anklicken.", "ui.show": "Einblenden", "ui.showAll": "Alle einblenden", @@ -4315,6 +4982,15 @@ "ui.slice": "Scheibe", "ui.slicer": "Slicer", "ui.slicer_delete_tip": "{program}\nMit Entf aus der Liste entfernen", + "ui.slicer_lost": "{slicer} wurde gestartet, kann aber nicht mehr gefunden werden, um ihm die Datei zu senden. Die Datei ist unter {path} gespeichert: Öffnen Sie sie von dort.", + "ui.slicer_no_file": "{slicer} ist bereit, aber es gibt keine Datei, die gesendet werden könnte.", + "ui.slicer_no_response": "{slicer} hat nicht rechtzeitig geantwortet. Die Datei ist bereits unter {path} gespeichert: Senden Sie sie erneut, sobald der Slicer gestartet ist.", + "ui.slicer_resend_hint": "Erneutes Senden ersetzt die Datei, die der Slicer geöffnet hat.", + "ui.slicer_single_instance": "Dateien in einem bereits laufenden Slicer öffnen", + "ui.slicer_single_instance_tip": "PrusaSlicer, SuperSlicer und Cura laden die Datei in das geöffnete Fenster, statt ein weiteres zu starten. Andere Slicer werden wie gewohnt gestartet. PrusaSlicer 3.0 tut das immer: Sein Starter kann eine Datei nicht anders übernehmen.", + "ui.slicer_starting": "{slicer} wird gestartet…", + "ui.slicer_still_starting": "Es wird weiter darauf gewartet, dass {slicer} startet. Sonst wurde nichts gesendet.", + "ui.slicer_stl_fallback": "Das 3MF konnte nicht geschrieben werden, daher ist dies ein STL.", "ui.slot": "Langloch", "ui.smooth": "Glatt", "ui.smooth_wheel_zoom": "Sanftes Zoomen mit dem Rad", @@ -4330,6 +5006,8 @@ "ui.start": "Start", "ui.start_over": "Von vorn beginnen", "ui.start_page": "Startseite", + "ui.step_values": "Werte mit Pfeiltasten und Mausrad schrittweise ändern", + "ui.step_values_tip": "In einem Wertefeld, in das Sie gerade tippen, ändern Auf/Ab eine einfache Zahl um 1 (Umschalt: 10, Alt: 0,1), ebenso das Mausrad über dem Feld. Ausdrücke bleiben wie eingegeben.", "ui.stitch_tolerance_hint": "eng", "ui.stretch": "Strecken", "ui.supersampling": "Supersampling (SSAA)", @@ -4373,6 +5051,7 @@ "ui.total_angle": "Gesamtwinkel", "ui.trackpad_pan_tip": "Scrollen mit zwei Fingern verschiebt die Ansicht; bei Fusion dreht Umschalt + zwei Finger die Ansicht. Aus: Scrollen zoomt. Mausrad und Pinch-Geste zoomen; das Daumenrad verschiebt seitlich.", "ui.trim": "Kürzen", + "ui.turn_up_axis": "Achse nach oben drehen", "ui.twist": "Verdrehung", "ui.two_finger_scroll_pans": "Zwei-Finger-Scrollen schwenkt", "ui.two_sides": "Beidseitig", @@ -4389,6 +5068,7 @@ "ui.up_axis_note": "Welche Achse in den Ansichten nach oben zeigt (Achsenansicht, Home, Ansehen). Designs bleiben unverändert.", "ui.updateToCurrentView": "Auf aktuelle Ansicht aktualisieren", "ui.upload": "Hochladen", + "ui.vertical_constraint": "Vertikale Bedingung", "ui.view_angles": "Ansichtswinkel", "ui.view_angles_convention": "Grad um die Hochachse. Gieren 0° ist die Vorderansicht, 90° links, −90° rechts, 180° die Rückansicht; Nicken 90° ist die Draufsicht; ein positives Rollen dreht das Modell im Uhrzeigersinn.", "ui.view_angles_help": "Zeigt Nick-, Gier- und Rollwinkel der Ansicht in Grad sowie die Projektion unter dem Orientierungs-Gizmo. Klicken Sie auf die Anzeige, um genaue Winkel einzugeben.", @@ -4421,6 +5101,10 @@ "ui.visual_wireframe": "Drahtmodell", "ui.wheel_zoom_speed": "Zoomgeschwindigkeit des Mausrads", "ui.width": "Breite", + "ui.window_system": "Fenstersystem", + "ui.window_system_auto": "Automatisch", + "ui.window_system_wayland": "Wayland", + "ui.window_system_x11": "X11", "ui.workspace": "Arbeitsbereich", "ui.workspace_animation_description": "Explosionsansichten, Bewegungsstudien und Storyboards", "ui.workspace_available": "Verfügbar", @@ -4462,11 +5146,13 @@ "update.installed": "SolidCraft {version} ist installiert. Zum Verwenden neu starten.", "update.installer_started": "Das Installationsprogramm startet, wenn SolidCraft geschlossen wird.", "update.later": "Später erinnern", + "update.managed": "Aktualisierung verfügbar: Aktualisieren Sie SolidCraft in {manager}", "update.no_notes": "Zu diesem Release gibt es keine Hinweise.", "update.package_hint": "Diese Installation stammt aus einem {kind}. Neues Paket herunterladen und installieren mit:", "update.prefs_dev": "Dies ist ein Entwicklungs-Build ({version}): er sucht nicht nach Updates.", "update.prefs_flatpak": "Flatpak aktualisiert diese Installation ({version}): {command} ausführen oder die Softwareverwaltung nutzen. SolidCraft sucht nicht selbst.", "update.prefs_label": "Updates", + "update.prefs_managed": "Dies ist SolidCraft {version}, verwaltet von {manager}. Die Prüfung fragt GitHub nach der neuesten Version und sendet nichts weiter; aktualisieren Sie SolidCraft in {manager}.", "update.prefs_note": "Dies ist SolidCraft {version} ({kind}). Die Suche fragt GitHub nach dem neuesten Release und sendet sonst nichts.", "update.prefs_web": "Laden Sie die Seite neu, um die neueste Web-App zu erhalten. Ist das Netzwerk nicht verfügbar oder kann das Update nicht rechtzeitig vollständig heruntergeladen werden, wird die gespeicherte Version geöffnet. Ein laufender Download wird für das nächste Neuladen fortgesetzt.", "update.refresh_checking": "Neueste Version wird gesucht…", @@ -4485,7 +5171,14 @@ "use a positive nominal diameter and a pitch smaller than it": "einen positiven Nenndurchmesser und eine kleinere Steigung verwenden", "values must be finite numbers": "Werte müssen endliche Zahlen sein", "values must be numbers or expressions": "Werte müssen Zahlen oder Ausdrücke sein", + "view.auto_ortho_faces": "Perspektivisch beim Drehen, orthografisch auf Flächen", "view.render_inspector": "Render-Inspektor", + "web.files.insert_drawings_hint": "CAD-Dateien, Netze, DXF oder SVG in das geöffnete Design", + "web.files.insert_stored_drawings_hint": "In das geöffnete Design (CAD-Dateien, Netze, DXF, SVG)", + "web.open_full": "In SolidCraft öffnen", + "web.open_link": "Verknüpftes Design öffnen?", + "web.open_link_confirm": "Es gibt nicht gespeicherte Änderungen. Das verknüpfte Design in einem neuen Tab öffnen?", "↑ ↓ choose · Enter / Tab complete · Esc dismiss": "↑ ↓ auswählen · Eingabe / Tab vervollständigen · Esc schließen", + "↑ ↓ choose · Enter applies what you typed · Esc dismiss": "↑ ↓ wählen · Enter wendet das Getippte an · Esc schließt", "⌘Enter continues on GitHub.": "⌘Enter geht weiter zu GitHub." } diff --git a/assets/locales/en-GB.json b/assets/locales/en-GB.json index 2d254b4de5..6e55dc2095 100644 --- a/assets/locales/en-GB.json +++ b/assets/locales/en-GB.json @@ -47,7 +47,13 @@ "color.edit": "Click to edit colour", "color.gamma": "Showing colour values in 0-255 gamma space", "color.linear": "Showing colour values in 0-1 linear space", + "color.recent": "Recent colours", + "color.recent_help": "Colours you pick in any colour field appear here", + "color.saturation": "Saturation (per cent)", "color.selected": "Selected colour", + "color.step_down": "Step down", + "color.step_up": "Step up", + "color.value": "Value, the brightness (per cent)", "dxf.cancelled": "DXF export cancelled", "dxf.cancelling": "Cancelling DXF export", "help.apache": "Apache licence 2.0", @@ -86,12 +92,18 @@ "prefs.sketch_tracking_guides": "Centre and midpoint alignment guides", "prefs.sketch_tracking_guides_note": "Hover over a circle centre or straight-edge midpoint, then move horizontally or vertically to show a temporary dotted guide.", "prefs.status_bar_note": "A thin bar along the window's bottom edge shows what is being edited, the open sketch's state in its colour and the design's units; a click on the units changes them. The sketch's state then no longer shows in the view's corner.", + "prefs.structure.appearance_note": "Colours, text and accessibility.", + "prefs.structure.tools_note": "How modelling tools behave.", "prefs.theme_follow": "Follow a theme file", "prefs.theme_follow_error": "Not applied: {error}. The last good theme stays on.", "prefs.theme_follow_launch": "Followed for this run only (--theme-file); reloaded whenever another tool rewrites it.", "prefs.theme_follow_note": "Reloaded whenever another tool rewrites it. Choosing a theme stops following it.", "prefs.theme_follow_off": "Off", "prefs.theme_follow_stop": "Stop following", + "prefs.theme_option_opacity_help": "Adjust floating panel opacity. A readability floor protects text over viewport colours.", + "prefs.theme_option_tab_colours": "Tab colours", + "prefs.theme_option_tab_colours_help": "Use this theme’s panel and viewport colours for each ribbon tab.", + "prefs.theme_option_tab_fade_ms_help": "Fade panel and viewport colours when switching tabs. Zero switches instantly.", "prefs.trackpad_zoom_modifier.help": "Hold only this key to zoom at the pointer with vertical scroll. Off keeps the existing scroll behaviour.", "prefs.view": "View behaviour", "prefs.viewport_background_colour": "Colour", @@ -234,6 +246,8 @@ "ui.default_modelling_orientation": "Default modelling orientation", "ui.design_properties.license": "Licence", "ui.design_properties.organization": "Organisation", + "ui.details_curvature_caption": "Faces are coloured by their local radius of curvature.", + "ui.details_draft_caption": "Faces are coloured by their angle to the pull direction.", "ui.export_keymap_tip": "Keys, S box favourites and the toolbar, as a file", "ui.layout_compact": "Compact", "ui.layout_floating": "Floating", diff --git a/assets/locales/en.json b/assets/locales/en.json index 97b51417a2..aba26bdbcd 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -822,6 +822,7 @@ "cmd.appearance_pick_up": "Pick Up Appearance", "cmd.appearance_replace": "Replace Appearance", "cmd.assembly_bom": "Bill of Materials", + "cmd.assembly_mass_properties": "Mass Properties", "cmd.body.print_face": "Print Face", "cmd.body_rename": "Rename Body", "cmd.browser_collapse_all": "Collapse All", @@ -1247,6 +1248,7 @@ "cmd.view.render_browser": "Render Browser", "cmd.view.render_dock": "Move Render Dock", "cmd.view.render_focus": "Render Focus View", + "cmd.view.status_details": "Viewport Status Details", "cmd.view.status_items": "Viewport Status Items", "cmd.view_ambient_occlusion": "Ambient Occlusion", "cmd.view_angle_readout": "View Angles", @@ -1285,8 +1287,14 @@ "color.hex_hint": "#rrggbb", "color.hue": "Hue", "color.linear": "Showing color values in 0-1 linear space", + "color.recent": "Recent colors", + "color.recent_help": "Colors you pick in any color field appear here", + "color.saturation": "Saturation (percent)", "color.saturation_value": "Saturation and value", "color.selected": "Selected color", + "color.step_down": "Step down", + "color.step_up": "Step up", + "color.value": "Value, the brightness (percent)", "config.column_filter_empty": "No matching columns", "contact.components_count": "{count} components", "contact.enabled_label": "enabled", @@ -1333,6 +1341,7 @@ "dialog.dimension_name": "Dimension: {name}", "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.", "dialog.select": "↖ Select", "dialog.selected_count": "↖ {count} selected", "dialog.selection_guidance": "Select an input to change it. Click selected objects to remove them; Ctrl/Cmd-click also toggles them. Review the preview, then press OK.", @@ -1390,21 +1399,168 @@ "from the rule": "from the rule", "help.apache": "Apache license 2.0", "help.appearance.assign": "Gives the selected bodies, faces or components a color or a material look, without changing their weight. Use it to tell parts apart or to preview a finish.", + "help.assembly.bom": "Lists the parts of the design or a component with their counts, by part number, and can save the list as CSV or JSON. Use it as the parts list for ordering and assembly.", "help.attributions": "Attributions (ATTRIBUTION.md)", "help.build": "Version {version} · commit {commit} · built {date}", + "help.canvas.decal": "Places an image on a planar face of the model, like a sticker. Use it to show a label, logo or printed pattern on a part.", + "help.canvas.edit": "Changes a canvas: its position, size, angle, opacity, mirroring, visibility or name. Use it to line up a reference image with the sketch you trace over it.", + "help.canvas.insert": "Places a PNG or JPEG image on a plane or face as a see-through reference. Use it to trace a photo or drawing in a sketch, with the image scaled to a known size.", + "help.component.break_link": "Cuts a linked component loose from its design file, keeping its bodies as they are. Use it when the part should no longer follow changes to the original file.", + "help.component.capture_position": "Keeps the positions of parts you moved by hand, so they become where those parts are. Use it after dragging parts into place that no joint holds.", + "help.component.create": "Adds a new, empty component to the design, inside the active component unless you choose another parent. Use it to give a new part of an assembly its own place for bodies and sketches.", "help.component.finish_edit": "Saves the part you are editing in place, returns to its assembly and updates the assembly from it. Use it when your changes to the linked part are done.", + "help.component.from_bodies": "Turns each selected body into a component of its own, named after the body. Use it when a design built as loose bodies should become an assembly whose parts can be joined and moved.", + "help.component.insert_design": "Inserts another saved design as a component linked to its file, with its solid bodies placed as they are there. Use it to reuse a part in an assembly and pick up later changes with Update Links.", + "help.component.properties": "Shows the volume, mass, centre of mass and moments of inertia of a component, using its bodies' materials. Use it to check weight and balance of an assembly.", + "help.component.revert_position": "Puts parts you moved by hand back where they were, dropping moves that were not captured. Use it to undo trial moves in an assembly.", + "help.component.update_links": "Reads the linked design files again and replaces bodies where they changed. Use it after editing a part that this assembly uses.", + "help.config.configure": "Adds parameters, features, materials or appearances as columns of the configuration table. Use it to build variants of one design, such as sizes of a part.", + "help.config.preview": "Checks that a row of the configuration table rebuilds, without changing the design or its history. Use it before switching to a variant you are not sure of.", + "help.config.rules": "Shows the rules of the configuration table, its rows and which row is active. Use it to see how the variants of a design are set up.", + "help.config.table": "Shows the configuration table: one row per variant, one column per parameter or feature, and whether the design still matches the active row. Use it to review the variants of a design.", "help.connect_note": "Restart each assistant to load the SolidCraft server. Each file was backed up as .bak before it changed.", "help.connect_title": "Connect AI assistants", + "help.construct.axis.edge": "Creates a construction axis along a straight edge or sketch line. Use it as the axis for a revolve, a circular pattern or a joint.", + "help.construct.axis.normal_to_face": "Creates a construction axis through a point, square to a face. Use it as an axis that stands straight out of a surface.", + "help.construct.axis.through_cylinder": "Creates a construction axis through the centre of a cylinder, cone or torus face, or a circular edge. Use it to turn or pattern features around a round part.", + "help.construct.axis.two_planes": "Creates a construction axis where two planes or flat faces meet. Use it when the axis you need is the line between two faces.", + "help.construct.axis.two_points": "Creates a construction axis through two points, vertices or sketch points. Use it for an axis that runs between two known positions.", + "help.construct.plane.along_path": "Creates a construction plane square to an edge or curve, at a position along it. Use it to sketch a profile for a sweep or a section of a path.", + "help.construct.plane.at_angle": "Creates a construction plane turned by an angle from a plane or face, about an axis or edge. Use it for sloped sketches, such as an angled bracket.", + "help.construct.plane.midplane": "Creates a construction plane halfway between two planes or flat faces, or bisecting them where they meet at an angle. Use it as a symmetry plane for mirroring.", + "help.construct.plane.offset": "Creates a construction plane parallel to a plane or flat face, at a distance or through a point. Use it to sketch at a set height above a face.", + "help.construct.plane.perpendicular": "Creates a construction plane that holds a line or edge and stands square to a plane or face. Use it to sketch upright on a model along an edge.", + "help.construct.plane.tangent": "Creates a construction plane that touches a cylinder, cone, sphere or torus at a point. Use it to sketch on the side of a round part.", + "help.construct.plane.three_points": "Creates a construction plane through three points. Use it for a plane set by positions on the model, such as three corners.", + "help.construct.plane.two_edges": "Creates a construction plane through two straight edges or lines that lie in one plane. Use it for a plane across a part, such as through two opposite edges.", + "help.construct.point.along_path": "Creates a construction point at a position along an edge or curve. Use it as a reference partway along a path.", + "help.construct.point.center": "Creates a construction point at the centre of a circular edge, sketch circle or arc, sphere or torus. Use it as a reference for holes, axes and joints.", + "help.construct.point.edge_plane": "Creates a construction point where an edge or line passes through a plane or flat face. Use it to mark where a path crosses a plane.", + "help.construct.point.three_planes": "Creates a construction point where three planes or flat faces meet. Use it as a corner reference, even where the model has no vertex.", + "help.construct.point.two_edges": "Creates a construction point where two edges or lines meet, or come closest. Use it as a reference at a virtual corner.", + "help.construct.point.vertex": "Creates a construction point on a vertex or sketch point. Use it as a reference that stays when the sketch or body around it is hidden.", + "help.contact.check": "Lists the pairs of parts in contact sets that pass through each other in their current position. Use it to find collisions before you drive joints or run a motion study.", + "help.contact.create": "Groups two or more parts into a contact set, whose bodies are kept from passing through each other while joints move. Use it for parts that should stop when they touch, such as a lever against its stop.", + "help.contact.disable_all": "Turns contact off, so driven joints and motion studies no longer stop where parts touch. Use it to move parts freely again, or to keep a large assembly responsive.", + "help.contact.edit": "Changes a contact set: the parts it holds, its name, or whether it is suppressed; it can also delete the set. Use it to adjust which parts are kept from passing through each other.", + "help.contact.enable_all": "Puts every part of the assembly into one contact set, so no two parts can pass through each other while joints move. Use it for a quick check of a whole mechanism.", + "help.contact.enable_sets": "Turns contact on for the contact sets you defined, so driven joints and motion studies stop where their parts would pass through each other. Use it to bring your sets back after turning contact off.", + "help.contact.list": "Lists the contact sets of the design, the parts in each one and whether contact is on. Use it to check how contact is set up before you drive the assembly.", + "help.document.inspect": "Shows an overview of the design: its components, bodies, sketches, features and parameters. Use it to get your bearings in a design you did not build yourself.", + "help.document.units": "Changes the units the design shows lengths in, such as mm or inches. The geometry keeps its size; only the display and plain numbers follow.", + "help.edit.checkpoint": "Remembers the design, the active sketch and the undo history as they are now. Use it before trying something risky that you may want to go back from.", + "help.edit.restore_checkpoint": "Goes back to a checkpoint, undo history included. Use it to drop everything done since the checkpoint in one step.", + "help.explode.create": "Creates an exploded view that pushes the parts away from the assembly's centre, or by the moves you set. Use it to show how an assembly goes together, for instructions or presentations.", "help.face.delete": "Removes the selected faces and lets the neighboring faces close the gap, or leaves the gap open as a surface. Use it to take off a rounded edge, a hole or a boss that is in the way.", + "help.file.insert_mesh": "Adds a 3MF, STL, OBJ, PLY or glTF file to the design as mesh bodies in a new component. Use it to place a downloaded or scanned model beside your design as a reference.", + "help.file.insert_step": "Adds a STEP, IGES, FreeCAD or Rhino file to the design as solid bodies in a new component; an assembly's parts become nested components. Use it to bring in models from suppliers or other CAD programs.", + "help.file.save_mesh": "Saves bodies as an STL, 3MF or OBJ mesh, with the smoothness of curved faces you choose. Use it to prepare parts for 3D printing or for programs that read meshes.", + "help.file.send_to_slicer": "Saves the selected bodies as a 3MF file and opens it in the slicer you choose. Use it to go from the model to printing in one step.", "help.font_details": "The interface uses egui's built-in fonts (Ubuntu Light and Hack, under the Ubuntu Font Licence and the MIT/Bitstream Vera license; Noto Emoji and the emoji icon font, under the OFL and MIT). Sketch text uses Liberation Sans (SIL OFL 1.1). The Crafting Apps' optional font pack, craft-fonts (BIZ UDPGothic, Shippori Mincho, BIZ UDMincho, Noto Sans CJK SC, Noto Sans Arabic; SIL OFL 1.1), is not bundled in SolidCraft builds.", "help.fonts": "Fonts", + "help.frame.member": "Places a profile, such as a tube or an aluminium extrusion, along the lines of a frame sketch, one member per line. Use it to build frames, racks and welded structures.", + "help.inspect.accessibility": "Colours the faces a tool can reach from one direction, and those it cannot. Use it to check a part for machining or moulding from that side.", + "help.inspect.center_of_mass": "Shows the centre of mass of bodies, each on its own and all together, at uniform density. Use it to check balance and where a part will tip.", + "help.inspect.colored_components": "Gives each component its own temporary colour, without changing appearances. Use it to tell the parts of an assembly apart at a glance.", + "help.inspect.compare": "Compares two bodies and shows where one has material the other does not, with volume and deviation. Use it to check a changed or imported part against the original.", + "help.inspect.continuity": "Checks how smoothly faces meet along their edges: gaps, kinks or curvature jumps, and shades the seams. Use it to find where surfaces do not join cleanly.", + "help.inspect.curvature_comb": "Draws combs along curves, edges or faces whose teeth show how strongly they bend. Use it to spot flat spots, kinks and uneven curvature.", + "help.inspect.curvature_map": "Colours faces by how strongly they curve, red where the radius is small. Use it to find tight spots and to judge how smooth a surface is.", + "help.inspect.draft": "Colours faces by their draft angle to a pull direction, marking those with too little. Use it to check a part before moulding or casting.", + "help.inspect.environment_map": "Shows the model with a reflective, mirror-like finish. Use it to judge the flow of surfaces by the way reflections run across them.", + "help.inspect.interference": "Finds bodies that overlap and shows each overlap with its volume. Use it to check an assembly for parts that collide.", + "help.inspect.isocurve": "Draws the isoparametric curves of faces, the grid the surface is built on. Use it to see how a surface flows and where it bunches up.", + "help.inspect.measure": "Measures lengths, distances, angles, areas and radii between what you pick, or the volume and mass of bodies. Use it to check sizes and clearances.", + "help.inspect.minimum_radius": "Finds the smallest radius of curvature of curves, edges or faces, and where it is. Use it to check that a tool or nozzle can follow the shape.", + "help.inspect.section": "Cuts the view open along a plane and hatches the cut faces, without changing the model. Use it to look inside parts and assemblies.", + "help.inspect.zebra": "Shows black and white stripes reflected on the model's faces. Use it to judge smoothness: stripes that break or kink at an edge show a poor transition.", + "help.joint.as_built": "Joins two parts where they already are, without moving either one, and sets how they may still move. Use it for parts you have already placed in their final position.", + "help.joint.cam": "Links a turning cam to a follower, so the follower slides or swings as the cam's outline passes under it. Use it for cams, eccentrics and valve drives.", + "help.joint.create": "Joins two parts at the joint origins you pick and moves the second part into place; the joint type sets how they may still move. Use it to build an assembly whose parts move like the real mechanism.", + "help.joint.drive": "Sets a joint's angle or distance and moves the parts with it, within its limits; linked joints and closed loops follow. Use it to check that a mechanism moves as intended.", + "help.joint.gear": "Links two turning joints with a gear ratio, so driving one turns the other. Use it for spur and ring gears, with the ratio from pitch radii or tooth counts.", + "help.joint.limits": "Sets how far a joint may turn or slide, as a minimum and a maximum, or clears those limits. Use it to keep a hinge, slider or lever within its real range of travel.", + "help.joint.motion_link": "Couples two joints with a ratio and an offset, so driving one moves the other. Use it for a fixed relation between joints that Gear, Rack and Pinion or Screw do not cover.", + "help.joint.origin": "Places a named joint origin on a part: at the centre of a face or round end, the middle of an edge, a corner or a point. Use it to prepare where joints attach; one picked on a face follows it when the part changes size.", + "help.joint.rack_pinion": "Links a turning pinion to a sliding rack, so the rack moves by the length of pitch circle the pinion rolls through. Use it for rack drives, steering and linear stages.", + "help.joint.reach": "Moves a point on a part toward a target by changing the joints on its chain, within their limits. Use it to pose a mechanism by where its end should be, such as the tip of a robot arm.", + "help.joint.rigid_group": "Locks the selected parts together as they are, so they move as one. Use it for parts that belong together and have no motion between them.", + "help.joint.screw": "Links turning and sliding by a pitch, so each turn advances by that distance. Use it for lead screws, bolts and threaded spindles.", + "help.library.apply": "Applies a material or appearance from a local library file to bodies, faces or components. Use it to reuse materials you saved from another design.", + "help.library.export": "Saves the design's materials and appearances to a library file. Use it to share them or reuse them in other designs.", + "help.library.inspect": "Checks a library file and lists its materials and appearances, without changing the design. Use it to see what a library holds before applying it.", "help.licences": "Licenses", "help.material.assign": "Sets what the selected bodies are made of, such as steel, aluminum or ABS. Use it so that the mass and weight match the real part.", + "help.material.properties.validate": "Checks a material data card for consistent numbers and missing values. Use it before relying on the data for mass or stiffness.", + "help.mesh.convert": "Rebuilds a mesh body, such as an STL, as an editable solid with flat and cylindrical faces. Use it to modify imported mechanical parts like a modelled body.", "help.mit": "MIT license", + "help.motion.study": "Sets joint values at steps along a timeline and plays them back as an animation. Use it to watch a mechanism move through a sequence and check clearances along the way.", + "help.motion.trace": "Traces the path a point on a part follows while a motion study plays or a joint is driven, and keeps it as a 3D sketch curve when you confirm. Use it to see where a mechanism reaches, or to build on its path.", "help.notice": "NOTICE", + "help.occurrence.ground": "Fixes a part in place, or frees it again, so that joints move the other parts around it. Use it for the frame or base that the rest of the assembly is built on.", "help.occurrence.move": "Moves an assembly part only along freedoms allowed by its joints. Select a part face, then drag an available arrow or arc.", "help.parameters.change": "Opens the table of named values and expressions that drive the dimensions of the design. Use it to change a size in one place and let every feature that uses it follow.", + "help.parameters.export": "Saves the design's parameters as CSV or JSON. Use it to edit them in a spreadsheet or move them to another design.", + "help.parameters.graph": "Shows which parameters and features use which others, and any loops or errors among them. Use it to see what a change to a parameter will affect.", + "help.parameters.import": "Reads parameters from CSV or JSON into the design, all or none of them. Use it to bring in values from a spreadsheet or another design.", + "help.parameters.move": "Moves a user parameter before or after another in the list. Use it to keep related parameters together.", + "help.parts.fastener": "Adds a standard screw, bolt, nut, washer, pin or bearing in the size you pick. Placed on a hole, it seats in the hole with a joint, so you need not model fasteners yourself.", + "help.parts.insert": "Inserts a standard part in the family, size and length you pick. Placed on the rim or wall of a hole, it seats on the hole with a rigid joint.", + "help.parts.library": "Opens the standard parts: screws, bolts, nuts, washers, set screws, dowel pins and bearings in standard sizes. Use it to add a standard part instead of modelling it yourself.", + "help.parts.resize": "Changes the size or length of an inserted standard part and rebuilds it; its joints keep it seated. Use it when a screw turns out too long or a hole needs another size.", + "help.plastic.assign_rule": "Assigns a plastic rule to bodies, so their plastic features take its draft and clearance as defaults. Use it to set up a moulded part once for all its features.", + "help.plastic.boss": "Adds a screw boss on a face: a post with an optional hole, draft, root fillet and supporting ribs. Use it for screw points in moulded or printed housings.", + "help.plastic.lip": "Adds a lip or a matching groove along the rim of a shelled body. Use it so that two housing halves locate on each other and close tightly.", + "help.plastic.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.", "help.pointcloud.insert": "Insert a PLY, XYZ or PTS scan as a point cloud alongside the model. Use it as a reference when building a model from measured data.", + "help.sheet.convert": "Turns a plate or shelled body of even thickness into a sheet metal body, with bends at the edges you pick. Use it to make an existing part unfoldable.", + "help.sheet.export_bend_table": "Saves the bend table of a sheet metal body as CSV or JSON: angles, radii and bend positions in the flat. Use it for the press brake or the workshop.", + "help.sheet.export_dxf": "Saves the flat pattern as a DXF, with outlines, bend lines and optional bend notes. Use it for laser, plasma or waterjet cutting.", + "help.sheet.flange": "Adds a flange: a base plate from a sketch, a wall along an open sketch contour, or a bent wall on sheet edges. Use it to build up sheet metal parts.", + "help.sheet.flat_pattern": "Creates the flat pattern of a sheet metal body, with its size, cut-outs and bend lines. Use it to check the blank before you cut it.", + "help.sheet.fold": "Bends a sheet along a sketch line by an angle and radius. Use it to fold a flat sheet after cutting features into it.", + "help.sheet.hem": "Folds a sheet edge back on itself. Use it for safe, stiff edges without sharp burrs.", + "help.sheet.manage_rules": "Creates and edits sheet metal rules: thickness, bend radius, K-factor, reliefs and gaps. Use them to match the material and tools of your workshop.", + "help.sheet.path_flange": "Adds a chain of flanges along a sheet edge, following an open chain of sketch lines. Use it for profiles such as a U or Z shape in one step.", + "help.sheet.perforate": "Adds a field of holes over the whole flat sheet, in a staggered or square pattern. Use it for vents, grilles and lighter panels.", + "help.sheet.refold": "Folds an unfolded sheet metal body back up. Use it after adding features to the flat sheet.", + "help.sheet.unfold": "Unfolds all bends of a sheet metal body. Use it to add holes or cuts that have to cross a bend, then refold.", + "help.sketch.arc.center_point": "Draws a circular arc around a centre point, from a start point to an end point. Use it when the arc's centre is known, such as a radius around a hole or a pivot.", + "help.sketch.arc.tangent": "Draws an arc that starts at the end of a line or arc and continues tangent to it. Use it to carry on a chain with a smooth bend instead of a corner.", + "help.sketch.arc.three_point": "Draws an arc from a start point to an end point through a third point. Use it when the arc has to pass through given points rather than around a known centre.", + "help.sketch.arc3d": "Draws an exact circular arc in space through three points, with 3D Sketch on. Use it for bends in paths and guide rails that leave the sketch plane.", + "help.sketch.auto_constrain": "Adds the constraints the sketch nearly has already, such as horizontal, parallel or coincident, then dimensions it until it is fully defined. Use it to lock a roughly drawn sketch in one step.", + "help.sketch.auto_constrain_from_datum": "Works like AutoConstrain, but measures the positions from a datum point you pick instead of the origin. Use it when the part is laid out from one reference point, such as a corner or a hole.", + "help.sketch.blend_curve": "Joins the ends of two curves with a smooth spline that is tangent to both, or also curvature continuous if you choose. Use it to bridge a gap between curves without a kink.", + "help.sketch.break": "Splits a curve into pieces where other curves cross it, or at points you place on it. Use it when part of a curve needs its own constraint or should be deleted on its own.", + "help.sketch.cancel": "Leaves the sketch and discards everything changed since it was created or opened for editing. Undo brings the changes back.", + "help.sketch.centerline": "Switches lines between normal lines and centerlines, axes that profiles leave out. Use a centerline as the axis for a revolve, a mirror or a symmetry constraint.", + "help.sketch.chamfer.distance_angle": "Bevels the corner between two lines, set by a distance along the first line and an angle. Use it when a drawing gives the chamfer as a length and an angle, such as 2 mm × 30°.", + "help.sketch.chamfer.equal_distance": "Bevels the corner between two lines the same distance back along both. Use it for the usual 45° chamfer on an outline.", + "help.sketch.chamfer.two_distance": "Bevels the corner between two lines, set by a separate distance along each line. Use it for chamfers that are not 45°, given as two lengths.", + "help.sketch.circle.center": "Draws a circle from its centre and its diameter. Use it for holes, bosses and other round features whose centre is known.", + "help.sketch.circle.three_point": "Draws a circle through three points. Use it when the circle has to pass through given points, for example of existing geometry.", + "help.sketch.circle.three_tangent": "Draws a circle that touches three lines, circles or arcs. Use it to fit a circle into the space between three curves, such as the inscribed circle of a triangle.", + "help.sketch.circle.two_point": "Draws a circle from the two ends of its diameter. Use it when the circle's edge points are known rather than its centre.", + "help.sketch.circle.two_tangent": "Draws a circle of a given size that touches two lines, circles or arcs. Use it for a rounding of fixed radius between two curves.", + "help.sketch.conic": "Draws a conic curve between two end points, shaped by an apex point and a rho value. Use it for smooth transitions whose fullness you want to tune, from flat to nearly a sharp corner.", + "help.sketch.constrainer": "Applies the constraint that one or two picked curves nearly have already, such as horizontal, parallel, tangent or equal. Use it to tidy a hand-drawn sketch one relation at a time.", + "help.sketch.constraint.coincident": "Makes a point lie on another point, a curve or a sketch axis. Use it to close gaps in a profile or to put a point on a line.", + "help.sketch.constraint.collinear": "Makes two lines lie on one straight line. Use it to line up interrupted edges, such as both sides of a notch.", + "help.sketch.constraint.concentric": "Gives two circles or arcs the same centre. Use it for rings, counterbores and shafts with shoulders.", + "help.sketch.constraint.curvature": "Makes two curves that meet at an end point continue with the same curvature (G2). Use it where a tangent join is not smooth enough, for example on visible surfaces.", + "help.sketch.constraint.equal": "Makes lines the same length, or circles and arcs the same radius. Use it so that one dimension drives several matching features.", + "help.sketch.constraint.fix": "Fixes points or curves where they are, or frees them again. Use it for geometry that must not move when you drag or solve the sketch.", + "help.sketch.constraint.horizontal_vertical": "Makes a line, or the line between two points, horizontal or vertical, whichever is closer. Use it to square an outline up to the sketch axes.", + "help.sketch.constraint.midpoint": "Puts a point on the middle of a line, or makes the midpoints of two lines meet. Use it to centre geometry on an edge.", + "help.sketch.constraint.parallel": "Makes two lines parallel. Use it for sides that must stay parallel when other dimensions change.", + "help.sketch.constraint.perpendicular": "Makes two lines meet at a right angle. Use it for square corners between lines that are not on the sketch axes.", + "help.sketch.constraint.polygon": "Makes a closed chain of lines a regular polygon: equal sides, with every corner on one circle. Use it to make a hand-drawn polygon regular and keep it so.", + "help.sketch.constraint.symmetry": "Makes two points or curves mirror each other across a line or about a point. Use it to keep a profile symmetric when you change one side.", + "help.sketch.constraint.tangent": "Makes two curves touch tangentially, such as a line and an arc. Use it for smooth joins without a kink.", + "help.sketch.construction": "Switches curves between normal geometry and construction geometry, which profiles leave out. Use construction curves as helpers for layout, references and dimensions.", "help.sketch.construction_line": "Create an infinite construction line through two points. Use it to lay out a sketch without splitting profiles.", "help.sketch.construction_line.angle": "Create an infinite construction line at an angle from sketch X. Pick the point and type the angle, or pick its direction.", "help.sketch.construction_line.bisector": "Pick two intersecting straight sketch lines, then choose either angle bisector. The result follows its source lines.", @@ -1415,12 +1571,71 @@ "help.sketch.construction_line.tangent_external": "Pick a circle or circular arc, then an external point and a tangent solution. The previews show only contacts on the drawn arc.", "help.sketch.construction_line.tangent_two": "Pick two circles or circular arcs, then choose a common tangent in the preview. Contacts must lie on the drawn curves.", "help.sketch.construction_line.vertical": "Create an infinite vertical construction line through a point. Its vertical constraint follows later edits.", + "help.sketch.copy": "Copies the selected sketch curves and points, together with the constraints and dimensions among them. Paste them into this sketch or another one.", + "help.sketch.create": "Starts a new 2D sketch on a plane or on a flat face of the model. Every extrude, revolve and sweep begins with a sketch.", + "help.sketch.cut": "Copies the selected sketch geometry and deletes it from the sketch, in one undo step. Paste it elsewhere in this sketch or into another one.", + "help.sketch.dimension": "Adds a driving dimension: a length, distance, angle, radius or diameter, depending on what you pick. Type a value or a parameter name, and the sketch follows it.", + "help.sketch.edit": "Opens an existing sketch for editing. The features built on it update when you finish the sketch.", + "help.sketch.ellipse": "Draws an ellipse from its centre, the end of its major axis and a point on its minor side. Use it for oval openings, cams and other elongated round shapes.", "help.sketch.equation_curve": "Draws a curve from formulas: x(t) and y(t), or y = f(x), over a range. The formulas can use parameters and the curve follows them: use it for a sine wave, a spiral or a gear tooth's involute.", + "help.sketch.extend": "Lengthens a line or arc up to the nearest curve in its way, or grows two curves until they meet. Use it to close gaps in a profile after trimming or editing.", + "help.sketch.fillet": "Rounds the corner where two lines or arcs meet with an arc of a given radius. Several corners rounded together share one radius.", + "help.sketch.finish": "Leaves the sketch and returns to the model. Its closed profiles are then ready for Extrude, Revolve and other features.", + "help.sketch.finish_auto_constrain": "Runs AutoConstrain on the sketch, then finishes it. Use it to leave a sketch fully defined in one step.", + "help.sketch.fit_curves_to_section": "Cuts a mesh or solid body with the sketch plane and fits lines and arcs to the section. Use it to rebuild a scanned or imported part as an editable sketch.", + "help.sketch.include_3d": "Brings model edges, vertices and curves of other sketches into this sketch as linked references, flattening those off its plane onto it. Use it to build on geometry that does not lie in the sketch plane.", + "help.sketch.infinite": "Switches lines between normal lines and infinite construction lines that run across the whole view. Use them as layout references that never end too short.", + "help.sketch.insert_dxf": "Reads the curves of a DXF drawing into a sketch, scaled, turned and placed where you set. Use it to build on a 2D drawing from another program, such as a laser-cut outline.", + "help.sketch.insert_svg": "Reads the paths of an SVG drawing into a sketch, scaled, turned and placed where you set. Use it to turn a logo or vector outline into curves you can extrude.", "help.sketch.inspect": "Opens a report on the open sketch: how many degrees of freedom are left and whether it is fully constrained. Use it to find out what still moves before you finish the sketch.", + "help.sketch.intersect": "Adds the curves or points where bodies, faces, edges or other sketches cross the sketch plane. They stay linked and follow the model when it changes.", + "help.sketch.intersection_curve": "Creates 3D curves where two bodies or faces meet, linked to them. Use them as paths or guide rails that follow the model.", + "help.sketch.isoparametric_curve": "Creates a 3D curve along a face's U or V direction through a point you pick, linked to the face. Use it as a path or split line that follows the surface.", + "help.sketch.line": "Draws connected lines point by point, as an open chain or a closed outline. It is the basic tool for most profiles.", + "help.sketch.line.midpoint": "Draws a line from its midpoint out to one end, with the other end mirrored. Use it for lines centred on a point, such as a symmetric edge.", + "help.sketch.line3d": "Draws connected straight lines between points in space, with 3D Sketch on. Use it for paths and frames that leave the sketch plane.", + "help.sketch.mirror": "Mirrors sketch curves and points across a line or axis and adds symmetry constraints. Use it to draw half of a symmetric profile and complete it.", + "help.sketch.move": "Moves or rotates sketch curves and points, or a copy of them. Use it to reposition geometry while keeping its shape and constraints.", + "help.sketch.offset": "Creates a copy of a chain of curves at a set distance, on one side or both. Use it for walls, outlines and clearances; the distance stays an editable dimension.", + "help.sketch.paste": "Pastes sketch geometry you copied or cut into the active sketch. Place it at a point or shift it by an offset.", + "help.sketch.pattern.circular": "Copies sketch geometry around a centre point, over a full circle or a set angle. Use it for bolt circles, spokes and teeth.", + "help.sketch.pattern.rectangular": "Copies sketch geometry in rows and columns at set spacings. Use it for grids of holes, slots or vents.", + "help.sketch.point": "Places a sketch point. Use it as a reference for dimensions, constraints, hole centres or later features.", + "help.sketch.point3d": "Places a point in space, with 3D Sketch on. Use it as a reference for 3D curves and paths.", + "help.sketch.polygon.circumscribed": "Draws a regular polygon around a circle, sized from its centre to the middle of a side. Use it when the width across flats is known, as for a hex nut.", + "help.sketch.polygon.edge": "Draws a regular polygon from the two ends of one side. Use it when the length and position of a side are known.", + "help.sketch.polygon.inscribed": "Draws a regular polygon inside a circle, sized from its centre to a corner. Use it when the size across corners is known.", + "help.sketch.project": "Brings model edges, faces, vertices and curves of other sketches into this sketch, projected onto its plane. They stay linked and follow the model when it changes.", + "help.sketch.project_to_surface": "Projects curves of other sketches along this sketch's normal onto a face, as linked 3D curves. Use it to put a logo, split line or path onto a curved surface.", + "help.sketch.rectangle.center": "Draws a rectangle from its centre and one corner. Use it for outlines centred on a point, such as the origin.", + "help.sketch.rectangle.rounded": "Draws a rectangle with all four corners rounded to one radius. Use it for plates, lids and openings with rounded corners.", + "help.sketch.rectangle.three_point": "Draws a rectangle from one edge and a point on the opposite side. Use it for rectangles at an angle to the sketch axes.", + "help.sketch.rectangle.two_point": "Draws a rectangle from two opposite corners. Use it for the outlines of plates, blocks and pockets.", + "help.sketch.scale": "Scales sketch curves and points by a factor about a base point. Use it to resize part of a sketch without drawing it again.", + "help.sketch.slot.arc_center": "Draws a curved slot of a given width along an arc set by its centre, start and end. Use it for adjustment slots around a pivot.", + "help.sketch.slot.arc_three_point": "Draws a curved slot of a given width along an arc through three points. Use it when the slot's path has to pass through given points.", + "help.sketch.slot.center_point": "Draws a straight slot of a given width from its centre to the centre of one end arc. Use it for slots centred on a point.", + "help.sketch.slot.center_to_center": "Draws a straight slot of a given width between the centres of its two end arcs. Use it for slots dimensioned centre to centre, such as adjustment slots for screws.", + "help.sketch.slot.overall": "Draws a straight slot of a given width from one outer end to the other. Use it when the slot's overall length is what matters.", + "help.sketch.spline.control_point": "Draws a cubic spline shaped by control points that it does not pass through. Use it for smooth, flowing curves that you shape by moving the points.", + "help.sketch.spline.control_point_5": "Draws a degree 5 spline shaped by control points. Use it where curves have to join with smoother curvature, for example on visible styled surfaces.", + "help.sketch.spline.fit_point": "Draws a smooth spline that passes through every point you place, open or closed. Use it to trace a shape through known points.", + "help.sketch.spline3d": "Draws a smooth curve in space through points, with 3D Sketch on. Use it for sweep paths and cable runs that leave the sketch plane.", + "help.sketch.spun_profile": "Creates in the sketch plane the outline a body sweeps when it turns about an axis. Use it to get the silhouette of a turned part for a revolve or a drawing.", + "help.sketch.text": "Writes text in the sketch in a chosen font, height and angle; its words can follow parameters. Extrude or emboss it for labels and lettering.", + "help.sketch.transform": "Scales, turns and moves sketch geometry about a centre point in one step. Use it to reposition and resize a selection at once.", + "help.sketch.trim": "Removes the piece of a curve between the nearest crossings around where you click. Use it to clean up overlapping lines into a closed profile.", "help.solid.align": "Moves the selected bodies so that a point, face or plane on them meets a point, face or plane elsewhere. Use it to place a part against another one without measuring the offset.", + "help.solid.boundary_fill": "Makes solids from the regions enclosed by overlapping tool bodies, filling only the regions you pick. Use it to build a shape from the space that several bodies bound together.", + "help.solid.bounding_solid": "Creates a box that encloses the selected bodies, with an optional margin. Use it to see a part's overall size, or as the stock it is machined from.", + "help.solid.box": "Creates a box from a length, width and height, from a corner point or centred on a point. Use it for a quick block, a placeholder or a body to cut features into.", "help.solid.chamfer": "Cuts the selected edges with a flat bevel of the distance you set. Use it to break sharp edges or to ease the entry of a pin or screw.", + "help.solid.coil": "Creates a helical body, such as a spring, from a diameter, turns, height or pitch and a round or square section. Use it for springs, coils and helical ridges.", "help.solid.combine": "Joins bodies, cuts them out of a target body, or keeps only the volume they share, and can keep the tool bodies. Use it to merge parts, to cut one shape with another or to find where two bodies overlap.", + "help.solid.cone": "Creates a cone from a base circle and a height, or a truncated cone when the top has a radius too. Use it for tapers, funnels, countersinks and points.", + "help.solid.cylinder": "Creates a cylinder from a radius or diameter and a height, standing on a point along an axis. Use it for shafts, pins, bosses and round blanks.", "help.solid.draft": "Tilts the selected faces by the angle you set, measured from a neutral plane. Use it on molded or cast parts so that they slide out of the mold.", + "help.solid.emboss": "Raises sketch profiles, such as text or a logo, out of a face, or sinks them into it. Use it for lettering, labels and markings on flat or round faces.", "help.solid.extrude": "Pushes a sketch profile into 3D along a distance, adding or removing material. Use it to turn a flat outline into a block, a boss or a pocket.", "help.solid.fillet": "Rounds the selected edges with a smooth circular blend of the radius you set. Use it to soften sharp edges for strength, comfort or looks.", "help.solid.hole": "Drills a round hole of the diameter you set at a chosen point on a face. Use it for screw clearances, pins and other simple round openings.", @@ -1429,20 +1644,60 @@ "help.solid.move": "Moves or rotates the selected bodies, or a copy of them, by the distance and angle you set. Use it to reposition a body or to make a second one in another place.", "help.solid.offset_face": "Pushes the selected faces in or out by the distance you set, and the faces around them follow. Use it to make a wall thicker, a pocket deeper or a boss taller without editing the sketch.", "help.solid.pattern.circular": "Repeats the selected objects around an axis, for the count and total angle you set. Use it for bolt circles, spokes, fins and teeth.", + "help.solid.pattern.path": "Copies features or bodies along a sketch path, by count and spacing. Use it for holes along a curved edge or parts along a rail.", "help.solid.pattern.rectangular": "Repeats the selected objects in one or two directions with the count and spacing you set. Use it for rows and grids of holes, ribs, pins or feet.", + "help.solid.pipe": "Creates a round tube along a path of sketch curves, solid or hollow with a wall thickness. Use it for tubes, wires, cables and rails.", "help.solid.press_pull": "Adds or removes material as you drag a face, or rounds edges as you drag them. Use it for quick changes straight on the model.", "help.solid.remove": "Takes the selected bodies out of the design from this point in the timeline on; earlier steps keep them. Use it to drop a helper body, such as a cutting tool you no longer need.", "help.solid.replace_face": "Moves the selected flat faces onto a plane or onto a parallel face, and the body grows or shrinks to meet it. Use it to bring a face flush with another part or a construction plane.", "help.solid.revolve": "Spins a sketch profile around an axis to make a round solid. Use it for shafts, knobs, bowls and any part that is round about a center line.", + "help.solid.rib": "Adds a thin wall along an open sketch curve, filled out to the next faces of the body. Use it to stiffen walls and corners, for example in moulded or printed parts.", "help.solid.scale": "Makes the selected bodies larger or smaller by a factor, the same in every direction or one per axis. Use it to resize an imported shape or to allow for shrinkage.", "help.solid.shell": "Hollows out a solid, leaving walls of the thickness you set, and can open the faces you pick. Use it for boxes, covers and other thin-walled parts.", + "help.solid.sphere": "Creates a sphere from a radius or diameter around a centre point. Use it for balls, knobs and rounded ends.", "help.solid.split_body": "Cuts a body into separate bodies with a plane, the face of another body or a sketch line. Use it to make parts that are printed, machined or colored on their own.", "help.solid.split_face": "Divides the selected faces along a plane, the face of another body or sketch curves, without cutting the body. Use it to give part of a face its own draft, color or offset.", "help.solid.sweep": "Drags a sketch profile along a path to make a solid. Use it for pipes, rails, handles and any part of constant section that follows a curve.", + "help.solid.thread": "Adds an ISO metric thread to a cylindrical face, either as a cosmetic thread or cut into the body. Use it for bolts, threaded holes and printed screw parts.", + "help.solid.torus": "Creates a ring from a major radius around its axis and a minor radius for its round section. Use it for O-rings, handles and round beads.", + "help.solid.web": "Adds a thin wall along each open sketch curve, each filled out to the next faces of the body. Use it for a grid of supporting walls inside housings and trays.", + "help.surface.blend": "Creates a surface that bridges the edges of two bodies, tangent or curvature continuous to both. Use it to join two surfaces smoothly across a gap.", + "help.surface.boundary": "Creates a surface filling a loop of two to four sketch curves, or a network of curves. Use it for free-form panels defined by their edges.", + "help.surface.extend": "Lengthens a surface past its edges, by a distance or up to a plane or face. Use it so that surfaces overlap before you trim or stitch them.", + "help.surface.extrude": "Pushes sketch curves or profiles along a distance into a surface, walls only, without caps. Use it for thin walls and for cutting or splitting tools.", + "help.surface.fair": "Smooths the inside of free-form faces while their edges stay put. Use it to even out bumps and wavy reflections in a surface.", + "help.surface.loft": "Creates a surface that passes through two or more sections in order, without caps. Use it for shells and transitions between different shapes.", + "help.surface.match": "Rebuilds the edge of a surface so it meets another body's edge, with matching tangent or curvature. Use it to close a seam smoothly between two surfaces.", + "help.surface.offset": "Creates a copy of faces at a distance along their normals, as a new surface body. Use it for skins, clearances or the other side of a wall.", + "help.surface.patch": "Fills a sketch region or a closed loop of edges with a face, flat or smoothly curved. Use it to close holes in a surface body.", + "help.surface.reverse_normal": "Turns faces of a surface body over, so their outside faces the other way. Use it before thickening or stitching when the faces point inward.", + "help.surface.revolve": "Turns sketch curves or profiles about an axis into a surface, without caps. Use it for round shells such as lampshades, bowls and nozzles.", + "help.surface.ruled": "Creates a strip of straight lines out from a chain of edges, along the face's normal, tangent or at an angle. Use it for draft walls, run-offs and parting surfaces.", + "help.surface.stitch": "Sews surface bodies together along their shared edges, into a solid when they close. Use it to turn a set of surfaces into one body.", + "help.surface.sweep": "Moves sketch curves or profiles along a path into a surface, without caps. Use it for gutters, trims and other thin shapes that follow a path.", + "help.surface.thicken": "Gives a surface body a thickness, turning it into a solid. Use it to make printable or machinable parts from surfaces.", + "help.surface.trim": "Cuts a surface with a plane, another surface or sketch curves, and keeps the side you pick. Use it to cut surfaces to shape before stitching.", + "help.surface.unstitch": "Separates faces of a body into surface bodies of their own; a solid comes apart into surfaces. Use it to rework single faces of a body.", + "help.surface.untrim": "Removes holes from a face, or restores its outer boundary to the surface's natural bounds. Use it to get back the full surface after a trim.", "help.third_party": "Third-party licenses", "help.timeline.compute_all": "Rebuilds every feature in the timeline from the start. Use it to refresh the model and to see which features fail.", "help.timeline.delete": "Deletes the selected features from the timeline; deleting a sketch also deletes the features built from it. Use it to remove a step that is no longer wanted.", + "help.timeline.dependents": "Lists the features that would be deleted with a feature, and those that would fail without it. Use it before deleting or changing a feature.", "help.timeline.play": "Replay the remaining features one at a time, with a half-second pause after each rebuild. Run this command again or press Escape to stop; Go to end jumps immediately.", + "help.view.apply": "Turns the view to a standard direction such as front, top or iso, or to a saved named view. Use it to look at the model from a fixed angle.", + "help.view.delete": "Deletes a saved named view. The model is not changed.", + "help.view.fit": "Zooms and pans so the whole model, or the selection, fills the view. Use it when the model has gone out of sight.", + "help.view.grid": "Shows or hides the grid in the viewport. Use it as a size and orientation guide while modelling.", + "help.view.ground_shadow": "Shows or hides the model's shadow on the ground. Use it to judge height and position more easily.", + "help.view.home": "Turns the view back to the home direction and fits the model. Use it to get back to a known view quickly.", + "help.view.list": "Lists the named views saved in the design. Use it to find a view you saved earlier.", + "help.view.navigate": "Turns, pans or zooms the view by steps, such as a quarter turn left. Use it to move the camera precisely instead of dragging.", + "help.view.navigation_bar": "Shows or hides the navigation bar at the bottom of the viewport. Hide it for more room, show it to reach the view tools by mouse.", + "help.view.rename": "Gives a saved named view a new name. The view itself stays the same.", + "help.view.save": "Saves the current camera position as a named view in the design. Use it to come back to the same angle later, for example for screenshots.", + "help.view.spin": "Orbits the view around the model continuously until the next click or key. Use it to present a model from all sides.", + "help.view.tool": "Latches a camera tool, orbit, pan or zoom, so that dragging with the left mouse button moves the view until you switch it off. Use it on a trackpad or without a middle mouse button.", + "help.view.visual_style": "Switches how the model is drawn: shaded with or without edges, wireframe, or with hidden edges. Use wireframe to see inside, shaded for a clean look.", "history.sketch_under_constrained": "Under constrained ({count} degrees of freedom)", "history.sketch_under_constrained_one": "Under constrained ({count} degree of freedom)", "hole_presets.confirm_delete": "Confirm delete", @@ -1656,6 +1911,7 @@ "nav.hfss.help": "Alt+left drag pans, Shift+Alt+left zooms, Ctrl+left drag on empty space orbits", "nav.inventor.help": "Middle drag pans, Shift+middle orbits, Ctrl+middle zooms, right drag orbits", "nav.maya.help": "Alt+left drag orbits, Alt+middle pans, Alt+right zooms; middle drag also pans", + "nav.nx.help": "Middle drag orbits, Shift+middle pans (NX's middle + right), Ctrl+middle zooms (NX's middle + left); right drag leaves the camera", "nav.openscad.help": "Left drag on empty space orbits, Shift+left drag box-selects, sketch geometry drags edit; right or middle drag pans, Shift+right or Shift+middle drag zooms", "nav.perspective.sketch": "Sketch editing uses orthographic view. Finish the sketch to switch perspective.", "nav.right_drag_orbits.help": "Off: a right drag leaves the camera alone and the right button only opens the context menu. Picking the SolidWorks mouse mapping turns it off.", @@ -1877,6 +2133,8 @@ "prefs.ctrl_cmd": "Ctrl / Cmd", "prefs.curved_surfaces": "Curved surfaces", "prefs.curves": "Curved surfaces", + "prefs.dark_parts_shading": "Keep the shape of dark parts", + "prefs.dark_parts_shading_help": "Shade the view in linear light with a soft sky fill, so dark faces keep a gradient instead of turning black", "prefs.dead_zone_note": "The hatched band is the dead zone: motion inside it is ignored.", "prefs.default": "default", "prefs.design": "Design defaults", @@ -1891,6 +2149,8 @@ "prefs.edges": "Edges and shading", "prefs.extend": "Extend", "prefs.extra_navigation": "Ctrl+left drag on empty space orbits; hold Space to pan.", + "prefs.extrude_view_isometric": "Turn the view to isometric when Extrude opens", + "prefs.extrude_view_return": "Go back to the earlier view when Extrude closes", "prefs.follows_os": "Follows the OS", "prefs.font": "Font", "prefs.font_fallback": "Glyphs the chosen font lacks fall back to the built-in font, so no label shows an empty box.", @@ -1906,6 +2166,7 @@ "prefs.graphics_card_auto": "Automatic", "prefs.graphics_card_missing": "The saved graphics card is unavailable; Automatic is used.", "prefs.graphics_card_restart": "Takes effect after restarting SolidCraft. WGPU_ADAPTER_NAME overrides this choice.", + "prefs.graphics_card_unused": "The saved graphics card was not used for this window; the card in use is shown in Help › About and in the status line. Try Automatic, or set WGPU_ADAPTER_NAME to a unique part of the card's name.", "prefs.graphics_note": "Trade speed for smoothness. Each choice shows its effect on a sample.", "prefs.graphics_presets_note": "Presets set the choices below together.", "prefs.hide_constraint_glyphs_zoomed_out": "Hide constraint glyphs when zoomed out", @@ -1920,6 +2181,8 @@ "prefs.history_sketches_note": "With Sketches set to Under their feature, the History at the left lists each sketch one level in, under the first feature built from it, whenever it is wide enough for names. The timeline at the bottom always keeps the creation order.", "prefs.history_sketches_under_feature": "Under their feature", "prefs.hold_stop_snapping": "Hold to stop snapping", + "prefs.home_fit_size": "Home and Fit size", + "prefs.home_fit_size_note": "How much of the view the model fills when Home, Fit, an opened design or a navigator view change frames it, whether or not the setting below is on. 100 %: fills the view between the panels, the compass and the navigation bar.", "prefs.icons_only": "Icons only", "prefs.imported_file": "Imported file", "prefs.in_browser": "In browser", @@ -1928,13 +2191,20 @@ "prefs.interface_language": "Interface language", "prefs.interface_note": "How SolidCraft looks and where its parts sit.", "prefs.interval_minutes": "{n} min", + "prefs.invert_navigator_arrows": "Invert the view cube rotation arrows", + "prefs.invert_navigator_arrows_note": "The arrows around the cube orbit the camera the way they point, as in Fusion and Inventor: the arrow on the left points right and swings the camera right. Off: an arrow rotates the cube toward the side it sits on, so the left arrow swings the camera left. The curved roll arrows are not affected.", + "prefs.keep_work_on_tab_switch": "Keep dialogs and tools open when switching designs", + "prefs.keep_work_on_tab_switch_note": "Each design tab keeps its open dialog or tool, with what you picked and typed in it, and its toolbar tab. They are there again when you come back to the design. Off: switching designs cancels them and shows the Model tab.", "prefs.labels": "Labels", + "prefs.language_system": "System", "prefs.layout": "Layout", "prefs.layout_note": "Compact docks panels to the window edges. Browser translucency is available in Floating. Change the Assistant side on the Assistant page.", "prefs.left": "Left", "prefs.left_click": "Left click", "prefs.line_drag_draws_one": "Press and drag draws one line", "prefs.line_drag_draws_one_note": "In the Line tool, pressing, dragging and letting go draws one line from the press to the release, and it ends there. Clicks still draw a chain. Off: a drag places a point where it is let go, as a click does.", + "prefs.line_hover_tangent_arc": "Rest on a line's last point to switch to a tangent arc", + "prefs.line_hover_tangent_arc_note": "In the Line tool, once a segment is drawn, move away from the chain's last point, come back and hold the pointer still on it: the next click ends a tangent arc, and the same gesture goes back to lines. Off: resting there does nothing.", "prefs.linked_designs": "Linked designs", "prefs.live_input": "Live input", "prefs.live_preview": "Live preview", @@ -1958,6 +2228,8 @@ "prefs.mouse_mapping": "Mouse mapping", "prefs.navigation": "Navigation", "prefs.navigation_note": "How the mouse, trackpad and 3D mouse move the view.", + "prefs.navigator_fit": "Fit the model after a navigator view change", + "prefs.navigator_fit_note": "A face, corner or arrow of the view cube, or an axis of the gizmo, also looks at the middle of the model from the one distance that fits it from every side (its bounding sphere). Home uses the same zoom and centre while this is on, so views chosen after it only turn. Off: the view turns in place, keeping its target and zoom.", "prefs.network_host_remove": "Remove", "prefs.network_locations": "Allow network locations in designs", "prefs.network_locations_note": "Off: a design's linked files on another computer (\\\\server\\share) are opened only from the servers listed here, or after you allow one when asked.", @@ -2006,6 +2278,8 @@ "prefs.render_previous_geometry_budget": "Use previous Render preview geometry budget", "prefs.render_previous_geometry_budget_help": "Restore the previous fixed geometry limit (512 MiB on 64-bit desktop, 128 MiB elsewhere) instead of the device-based budget.", "prefs.reports_saved": "{n} saved", + "prefs.ribbon_click_ends_tool": "Clicking the running tool's button ends it", + "prefs.ribbon_click_ends_tool_note": "A click on the lit ribbon button of the sketch tool in use ends the tool, so clicks in the view select again. Off: the click starts the tool again.", "prefs.ribbon_reveal": "Show search results in the ribbon", "prefs.ribbon_reveal_note": "Hovering a result in Search commands shows where the command sits in the ribbon: its tab, its button or its row in a panel's drop-down. A command started from the search blinks there, and a running command from a drop-down marks its panel.", "prefs.right_click": "Right click", @@ -2035,6 +2309,8 @@ "prefs.sketch_exit_view.previous": "Return to the previous view", "prefs.sketch_exit_view.unless_rotated": "Return unless rotated", "prefs.sketch_exit_view_note": "Where the view goes after Finish Sketch, Cancel or a double Esc.", + "prefs.sketch_face_activates_part": "Sketching on a part's face makes that part active", + "prefs.sketch_face_activates_part_note": "A sketch started on a face of another component's body activates that component first, so the sketch belongs to it and stays with that part in the browser. The part stays active afterwards. Off: the sketch belongs to the active component.", "prefs.sketch_open_look_at": "Turn to face a sketch when it opens", "prefs.sketch_open_look_at_note": "Off: creating or editing a sketch leaves the view as it is. Look At faces the sketch when you want it.", "prefs.sketch_recent_snap": "Auto snap to recent centers and midpoints", @@ -2048,6 +2324,8 @@ "prefs.sketch_tab_always_note": "The Sketch tab stays in the ribbon, right after Model, while no sketch is open. Its drawing tools then start a sketch on the selected plane or face, or on the plane you pick next, with the tool ready in it.", "prefs.sketch_tracking_guides": "Center and midpoint alignment guides", "prefs.sketch_tracking_guides_note": "Hover over a circle center or straight-edge midpoint, then move horizontally or vertically to show a temporary dotted guide.", + "prefs.sketch_tracking_points": "Alignment guides from end points and corners while drawing", + "prefs.sketch_tracking_points_note": "While a drawing tool waits for a point, hovering a line or arc end, a rectangle corner or a free point shows the same temporary dotted guide as a center or midpoint. Off: only centers and midpoints.", "prefs.sketching": "Sketching", "prefs.snap_drawing_note": "While drawing or dragging a sketch point.", "prefs.source": "Source", @@ -2064,6 +2342,24 @@ "prefs.status_error": "Error", "prefs.status_selected": "Selected", "prefs.status_warning": "Warning", + "prefs.structure.appearance": "Appearance", + "prefs.structure.appearance_note": "Colors, text and accessibility.", + "prefs.structure.in_profile": "In profile", + "prefs.structure.layout": "Layout", + "prefs.structure.layout_note": "Where the parts of the window sit.", + "prefs.structure.look": "Look", + "prefs.structure.measure_pointer": "Measure and pointer", + "prefs.structure.mouse3d": "3D mouse", + "prefs.structure.mouse3d_note": "SpaceMouse and SpaceNavigator.", + "prefs.structure.navigation_note": "Mouse and trackpad. The 3D mouse has its own page.", + "prefs.structure.profile": "Profile", + "prefs.structure.profile_edited": "Changed since saved", + "prefs.structure.profile_hover": "Saved in settings profiles", + "prefs.structure.scale_percent": "{percent}%", + "prefs.structure.sketch": "Sketch", + "prefs.structure.tools": "Tools", + "prefs.structure.tools_note": "How modeling tools behave.", + "prefs.structure.work": "Work", "prefs.supersampling_brief": "Draws the view larger and filters it down: very crisp, and heavier.", "prefs.tab_accent_note": "The accent follows the active tab", "prefs.theme_file": "theme file", @@ -2075,6 +2371,16 @@ "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_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", + "prefs.theme_option_tab_colours_help": "Use this theme’s panel and viewport colors for each ribbon tab.", + "prefs.theme_option_tab_fade_ms": "Tab transition", + "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_reset": "Reset", + "prefs.theme_options_unavailable": "Unavailable on this device", "prefs.theme_paste": "Paste theme", "prefs.theme_paste_none": "The clipboard holds no SolidCraft theme", "prefs.timeline_half_hour": "−30 min", @@ -2109,6 +2415,8 @@ "prefs.viewport_background_theme": "Theme's gradient", "prefs.viewport_section_status": "Show viewport section status", "prefs.viewport_section_status_help": "Show the section setting in the viewport. Escape and the active ribbon button turn it off. Disable to keep the previous section controls.", + "prefs.viewport_status_details": "Live viewport details", + "prefs.viewport_status_details_help": "Edit active analysis and section settings in standard details cards. Disable to restore read-only analysis keys and the Section dialog.", "prefs.viewport_status_summary": "Fold viewport status items", "prefs.viewport_status_summary_help": "Fold three or more viewport status items into one summary badge. Disable to keep every item in the full row.", "prefs.wheel": "Wheel", @@ -3549,6 +3857,7 @@ "sketch_text.formula": "Start with = to build the words from parameters: ='W' + number(Width)", "sketch_text.italic": "Italic", "sketch_text.loading_fonts": "Looking for installed fonts…", + "sketch_text.no_bold": "The built-in font has no bold face: choose an installed font", "sketch_text.on_path": "The text follows its path: the curve places it", "sketch_text.path_clear": "Take the text off its curve", "sketch_text.path_hint": "A curve of the sketch the text follows: click here, then the curve", @@ -3757,6 +4066,7 @@ "ui.agent_chat_auth_terminal": "Run this in a terminal, then press Connect again:", "ui.agent_chat_auth_title": "Sign in to the agent", "ui.agent_chat_cancel": "Cancel", + "ui.agent_chat_cannot_start": "Installed, but it does not start: {error}", "ui.agent_chat_changes": "Changes: {n}, one undo step", "ui.agent_chat_changes_n": "{n} changes", "ui.agent_chat_changes_of": "{n}/{limit} changes", @@ -3849,6 +4159,7 @@ "ui.agent_chat_queued_stopped": "Queued (stopped, not sent)", "ui.agent_chat_refresh": "Refresh", "ui.agent_chat_refused": "Not allowed for the agent: {reason}", + "ui.agent_chat_reinstall": "{agent} is installed ({program}), but it did not start when SolidCraft checked it: {error}\nIts installation may be incomplete, or made for another kind of computer. Install it again with `{install}`, check that it prints its version in a terminal, then press Refresh.", "ui.agent_chat_remove": "Remove from the queue", "ui.agent_chat_remove_file": "Take it off this message", "ui.agent_chat_remove_imported": "Take it off this message; the part stays in the design (Undo removes it)", @@ -3912,12 +4223,17 @@ "ui.ai_copy_suffix": "copy", "ui.ai_custom_url": "Custom URL", "ui.ai_default": "Default", + "ui.ai_edit": "Edit", + "ui.ai_edit_agent_title": "Edit agent", + "ui.ai_edit_gone": "This agent was removed, so there is nothing to save.", + "ui.ai_edit_tip": "Change this agent's name, command or endpoint. A saved key stays hidden.", "ui.ai_http_busy": "the endpoint is busy or over its limit; try again later", "ui.ai_http_key": "the endpoint refused the key: check it in Preferences > AI", "ui.ai_http_not_found": "no such endpoint or model: check the base URL and the model name", "ui.ai_http_too_large": "the conversation is too large for this endpoint; start a new chat", "ui.ai_key": "API key", "ui.ai_key_hint": "optional; kept in the system keychain", + "ui.ai_key_keep_hint": "leave empty to keep the saved key", "ui.ai_key_needs_https": "A key is only sent over HTTPS, or to this computer.", "ui.ai_key_note": "Stored in the system keychain, never in SolidCraft's settings. Leave it empty for a local server.", "ui.ai_keychain_error": "The system keychain refused: {error}", @@ -3944,11 +4260,15 @@ "ui.ai_permissions": "Permissions", "ui.ai_preset": "Preset", "ui.ai_protocol": "Protocol", + "ui.ai_reasoning_effort": "Reasoning effort", + "ui.ai_reasoning_effort_field_hint": "empty = provider default", + "ui.ai_reasoning_effort_hint": "Optional. Sent to the endpoint as `reasoning_effort`; accepted values differ per provider (for example `low`, `high` or `max`). A value you set here is never replaced by SolidCraft. Leave empty to send nothing and keep the provider's default.", "ui.ai_remove": "Remove", "ui.ai_request_timed_out": "The model took longer than {seconds} s. You can raise the request timeout for this endpoint in Preferences › Assistant.", "ui.ai_request_timeout": "Request timeout", "ui.ai_request_timeout_hint": "How long one model request to this endpoint may take, from 30 s to an hour. Raise it for a slow local model.", "ui.ai_reset": "Reset", + "ui.ai_save": "Save", "ui.ai_settings": "AI settings…", "ui.ai_show_features": "Show AI features", "ui.ai_show_features_note": "The agent chat: a panel where a coding agent you already use, or a model endpoint you choose, works on the open design. Off: no AI button, shortcut, menu entry or panel, and nothing is looked for or started.", @@ -4108,6 +4428,7 @@ "ui.dark": "Dark", "ui.default_modelling_orientation": "Default modeling orientation", "ui.delete": "Delete", + "ui.delete_select_first": "Select what to delete first: features, bodies, components or sketch geometry", "ui.depth": "Depth", "ui.design": "Design", "ui.design_properties.author": "Author", @@ -4122,6 +4443,40 @@ "ui.design_properties.project": "Project", "ui.design_properties.revision": "Revision", "ui.design_saved": "Saved {path}", + "ui.details_access_caption": "Faces show whether a tool can reach them from the chosen direction.", + "ui.details_angle": "Angle", + "ui.details_continuity_caption": "Edit the selected seam. Other seams keep their own settings.", + "ui.details_continuity_stale": "Earlier geometry: rerun the analysis to edit or frame this seam.", + "ui.details_copy_list": "Copy list", + "ui.details_curvature": "Curvature tolerance", + "ui.details_curvature_caption": "Faces are colored by their local radius of curvature.", + "ui.details_direction": "Tool direction", + "ui.details_draft_caption": "Faces are colored by their angle to the pull direction.", + "ui.details_edit_seam": "Edit seam", + "ui.details_fixture": "Component specimen — sample data only", + "ui.details_fixture_caption": "Component specimen with illustrative values; this is not a model result.", + "ui.details_fixture_problem": "Example finding with a problem", + "ui.details_fixture_readout": "Example measured value", + "ui.details_flip": "Flip", + "ui.details_gap": "Gap", + "ui.details_hatch": "Hatch cut faces", + "ui.details_jump": "Curvature jump", + "ui.details_key": "Key", + "ui.details_offset": "Offset", + "ui.details_plane": "Plane", + "ui.details_pull": "Pull direction", + "ui.details_radius": "Radius", + "ui.details_readout": "Readout", + "ui.details_samples": "Samples", + "ui.details_seam": "{body} · Edge {edge}", + "ui.details_settings": "Settings", + "ui.details_sharp_gap": "Sharp or gap", + "ui.details_show_all": "Show all {count}", + "ui.details_smooth": "Smooth", + "ui.details_stripes": "Stripes", + "ui.details_target": "Frame geometry", + "ui.details_worst_seams": "Worst seams", + "ui.details_zebra_caption": "Reflected stripes show how smoothly faces join.", "ui.diameter": "Diameter", "ui.dimension": "Dimension", "ui.direct_interaction": "Direct interaction", @@ -4166,6 +4521,7 @@ "ui.edit_in_place_saved_elsewhere": "The part was saved to another file; the assembly still links {name}", "ui.edit_in_place_wait": "Wait until what is running in the design is done", "ui.edit_section_analysis": "Edit Section Analysis", + "ui.edit_sketch_empty": "No sketches to edit", "ui.ellipse": "Ellipse", "ui.emboss_wrap_note": "Orthogonal line profiles tangent to one supported cylinder wrap automatically. Pick a wall to choose a target.", "ui.end_radius": "End Radius", @@ -4176,12 +4532,15 @@ "ui.equation_explicit": "Explicit", "ui.equation_form": "Form", "ui.equation_note": "The variable is a plain number: sin(t) takes it in radians. A result without a unit is in the design's unit.", + "ui.equation_note_range": "The variable is a length, as its range is: mix it with length parameters, and divide it by a length for sin(). A result without a unit is in the design's unit.", "ui.equation_parametric": "Parametric", + "ui.equation_variable_unit": "Variable has the range's unit", "ui.equation_x_of_t": "x(t)", "ui.equation_y_of_t": "y(t)", "ui.equation_y_of_x": "y(x)", "ui.equivalent_radius": "Equivalent Radius", "ui.expand": "Expand", + "ui.explode_needs_two_components": "Exploded View pulls components apart, so the design needs two or more of them (New Component, Insert Design or Create Components from Bodies)", "ui.exportDxf": "Export DXF…", "ui.export_ellipsis_keymap": "Export…", "ui.export_keymap_tip": "Keys, S box favorites and the toolbar, as a file", @@ -4350,12 +4709,14 @@ "ui.joints": "Joints", "ui.keyboard_shortcuts": "Keyboard Shortcuts", "ui.language": "Language", + "ui.lasso_select": "Lasso (drag a freehand loop: takes what is wholly inside)", "ui.layout_compact": "Compact", "ui.layout_floating": "Floating", "ui.length": "Length", "ui.length_decimals": "Length decimals", "ui.light": "Light", "ui.line": "Line", + "ui.line_arc_hint": "Tangent arc: click the arc's end. Rest on the last point again for lines; right-click or Esc to finish", "ui.link_copies": "Link Identical Copies", "ui.link_copies_keep": "Keep", "ui.link_copies_link": "Link", @@ -4444,6 +4805,7 @@ "ui.part_size_shared": "Rebuilds the part, replacing any added features. All occurrences of this component update.", "ui.paste": "Paste", "ui.path": "Path", + "ui.pattern_object_type_tip": "Bodies: solid shapes, picked in the view or the Browser; each is copied whole.\nFeatures: steps of the History, such as an extrude or a hole, picked in the History or on a face they made; the step is repeated.\nA sketch is neither: repeat its curves with the sketch's own Pattern or Mirror, or extrude it first.", "ui.pattern_on_path": "Pattern on Path", "ui.perforate_margin_default": "from the diameter", "ui.perforate_pitch": "Pitch", @@ -4509,6 +4871,7 @@ "ui.properties.inertia_y": "Iyx, Iyy, Iyz", "ui.properties.inertia_z": "Izx, Izy, Izz", "ui.properties.measuring": "Measuring…", + "ui.properties.measuring_bodies": "Measuring… {done} of {total} bodies", "ui.properties.measuring_failed": "Measuring stopped before it finished.", "ui.properties.principal_axis": "Principal axis {number}", "ui.properties.principal_moments": "Principal moments", diff --git a/assets/locales/es.json b/assets/locales/es.json index 9112d0fa2a..f039ed860b 100644 --- a/assets/locales/es.json +++ b/assets/locales/es.json @@ -822,6 +822,7 @@ "cmd.appearance_pick_up": "Tomar apariencia", "cmd.appearance_replace": "Reemplazar apariencia", "cmd.assembly_bom": "Lista de materiales", + "cmd.assembly_mass_properties": "Propiedades de masa", "cmd.body.print_face": "Cara de impresión", "cmd.body_rename": "Cambiar nombre del cuerpo", "cmd.browser_collapse_all": "Contraer todo", @@ -1285,8 +1286,14 @@ "color.hex_hint": "#rrggbb", "color.hue": "Tono", "color.linear": "Mostrando los valores de color en espacio lineal 0-1", + "color.recent": "Colores recientes", + "color.recent_help": "Los colores que elija en cualquier campo de color aparecen aquí", + "color.saturation": "Saturación (porcentaje)", "color.saturation_value": "Saturación y valor", "color.selected": "Color seleccionado", + "color.step_down": "Bajar un paso", + "color.step_up": "Subir un paso", + "color.value": "Valor, el brillo (porcentaje)", "config.column_filter_empty": "Sin columnas coincidentes", "contact.components_count": "{count} componentes", "contact.enabled_label": "activado", @@ -1775,6 +1782,11 @@ "not a move": "no es un movimiento", "not a number": "no es un número", "open a sketch first": "abre primero un boceto", + "parameters.apply_live": "Aplicar en vivo", + "parameters.apply_live_tip": "Aplica los cambios de parámetros mientras los editas", + "parameters.held": "Mantenidos ({count})", + "parameters.held_hint": "Parámetros mantenidos durante la edición", + "parameters.held_one": "Uno mantenido", "parts.automatic_name": "automático", "parts.dimension.across_flats": "Entrecaras", "parts.dimension.bore": "Diámetro interior", @@ -1965,6 +1977,8 @@ "prefs.origin_plane_names": "Nombres de los planos de origen", "prefs.origin_plane_names_note": "Nombres que se muestran en el explorador, diálogos y listas. {top} es XY, {front} es XZ y {right} es YZ", "prefs.overall_speed": "Velocidad general", + "prefs.parameter_list_rows": "Filas de la lista de parámetros", + "prefs.parameter_list_rows_tip": "Número de filas visibles en la lista de parámetros", "prefs.persistent_ribbon_pressed": "Resaltar comandos activos de la cinta", "prefs.persistent_ribbon_pressed_help": "Resaltar comandos de la cinta mientras sus efectos están activos. Desactivar para mantener el comportamiento anterior.", "prefs.perspective_fov": "Campo de visión", @@ -2041,6 +2055,8 @@ "prefs.sketch_tab_always_note": "La pestaña Boceto permanece en la cinta, tras Modelo, mientras no haya ningún boceto abierto. Sus herramientas de dibujo inician un boceto en el plano o cara seleccionados, o en el que elijas a continuación, con la herramienta lista.", "prefs.sketch_tracking_guides": "Guías de alineación a centro y punto medio", "prefs.sketch_tracking_guides_note": "Al pasar el ratón por el centro de un círculo o el punto medio de un borde recto, muévete horizontal o verticalmente para mostrar una guía punteada temporal.", + "prefs.sketch_tracking_points": "Guías de alineación desde extremos y esquinas al dibujar", + "prefs.sketch_tracking_points_note": "Mientras una herramienta de dibujo espera un punto, al pasar el ratón por el extremo de una línea o un arco, la esquina de un rectángulo o un punto libre se muestra la misma guía punteada temporal que en un centro o punto medio. Desactivado: solo centros y puntos medios.", "prefs.sketching": "Bocetos", "prefs.snap_drawing_note": "Mientras dibujas o arrastras un punto del boceto.", "prefs.source": "Origen", @@ -2089,6 +2105,8 @@ "prefs.under_cursor": "Bajo el cursor", "prefs.units_precision": "Unidades y precisión", "prefs.updates": "Actualizaciones", + "prefs.value_boxes_in_view": "Cuadros de valor en la vista", + "prefs.value_boxes_in_view_tip": "Muestra los cuadros de valor de los parámetros en la vista 3D", "prefs.view": "Comportamiento de la vista", "prefs.view_confirm_corner": "Aceptar y Cancelar en la esquina de la vista", "prefs.view_confirm_corner_note": "Mientras un diálogo o un boceto está abierto, una marca y una equis en la esquina superior derecha de la vista hacen lo que el Aceptar y el Cancelar del diálogo, o terminan y cancelan el boceto. El navegador de vistas se mueve debajo.", @@ -2098,6 +2116,10 @@ "prefs.viewport_background": "Fondo", "prefs.viewport_background_colour": "Color", "prefs.viewport_background_theme": "Degradado del tema", + "prefs.viewport_section_status": "Estado de la sección del visor", + "prefs.viewport_section_status_help": "Muestra el estado de la sección activa en la barra de estado", + "prefs.viewport_status_summary": "Resumen del estado del visor", + "prefs.viewport_status_summary_help": "Muestra un resumen del estado en la barra de estado del visor", "prefs.wheel": "Rueda", "prefs.wheel_trackpad": "Rueda y panel táctil", "prefs.workspace": "Espacio de trabajo", @@ -2976,7 +2998,9 @@ "render.output_ready": "Listo para renderizar", "render.output_run_check": "Ejecutar comprobación", "render.output_size_limit": "{method}: hasta {dimension} px por lado y {pixels} píxeles. Tamaño máximo que cabe: {width} × {height}.", + "render.output_size_method_profile": "Método: {method}, perfil: {profile}, {width}x{height}", "render.output_size_ok": "Tamaño OK · {width} × {height} px", + "render.output_size_profile": "Perfil: {profile}, {width}x{height}", "render.output_swap_orientation": "Intercambiar ancho y alto", "render.output_tools_inspector": "Edita el tamaño y el formato en el inspector de Salida.", "render.output_view_image": "Ver imagen", @@ -3189,6 +3213,7 @@ "render.size_custom": "Personalizado…", "render.size_full_hd": "Full HD", "render.size_hd": "HD", + "render.size_needs_profile": "El tamaño {size} necesita el perfil {profile}", "render.size_presets": "Preestablecidos de tamaño", "render.size_presets_pending": "Llega con el editor de Salida", "render.size_preview": "Previsualización", @@ -3476,6 +3501,24 @@ "sheet.flange_type_contour": "Contorno", "sheet.flange_type_edge": "Arista", "sheet.no_body": "no hay ningún cuerpo de chapa: crea uno con Pestaña > Base o convierte una placa en chapa con Convertir a chapa (elige su cara plana grande)", + "sheet.personal_rules.confirm_delete": "Confirmar eliminación", + "sheet.personal_rules.delete": "Eliminar", + "sheet.personal_rules.delete_prompt": "¿Eliminar esta regla personal?", + "sheet.personal_rules.full": "Completa", + "sheet.personal_rules.invalid_name": "Nombre no válido", + "sheet.personal_rules.invalid_values": "Valores no válidos", + "sheet.personal_rules.missing": "Falta", + "sheet.personal_rules.name": "Nombre", + "sheet.personal_rules.name_exists": "El nombre ya existe", + "sheet.personal_rules.name_hint": "Nombre de la regla personal", + "sheet.personal_rules.overwrite": "Sobrescribir", + "sheet.personal_rules.rename": "Cambiar nombre", + "sheet.personal_rules.rule": "Regla", + "sheet.personal_rules.save": "Guardar", + "sheet.personal_rules.scope": "Ámbito", + "sheet.personal_rules.select": "Seleccionar", + "sheet.personal_rules.title": "Título", + "sheet.personal_rules.unsupported": "No compatible", "shortcut.json_error": "los parámetros deben estar en formato JSON: {error}", "shortcut.no_command": "no existe el comando `{id}`; el texto anterior al JSON debe ser un ID de comando", "shortcut.pin": "Fijar en la parte superior del cuadro S", @@ -3512,6 +3555,7 @@ "sketch_text.align_right": "Alinear a la derecha", "sketch_text.bold": "Negrita", "sketch_text.builtin_font": "{family} (integrada)", + "sketch_text.formula": "Fórmula", "sketch_text.italic": "Cursiva", "sketch_text.loading_fonts": "Buscando fuentes instaladas…", "sketch_text.on_path": "El texto sigue su trayectoria: la curva determina su posición", @@ -3652,6 +3696,8 @@ "ui.3d_mouse_section": "Ratón 3D", "ui.3d_mouse_show_in_view": "Mostrar el ratón 3D en la vista 3D", "ui.3d_mouse_show_in_view_tip": "Una imagen de tu ratón 3D en la esquina inferior izquierda que se mueve cuando lo mueves.", + "ui.3d_mouse_sketch_orbit": "Órbita de boceto con ratón 3D", + "ui.3d_mouse_sketch_orbit_tip": "Usa el ratón 3D para orbitar en el boceto", "ui.3d_mouse_skip": "Mi dispositivo no puede hacer esto", "ui.3d_mouse_skip_tip": "Omite este movimiento en ambos sentidos; la calibración lo deja fuera.", "ui.3d_mouse_speed": "Velocidad del ratón 3D", @@ -3815,6 +3861,8 @@ "ui.agent_chat_remove": "Quitar de la cola", "ui.agent_chat_remove_file": "Quitar de este mensaje", "ui.agent_chat_remove_imported": "Quitar de este mensaje; la pieza permanece en el diseño (Deshacer la elimina)", + "ui.agent_chat_reply_cut_off": "Respuesta del agente cortada", + "ui.agent_chat_reply_declined": "Respuesta del agente rechazada", "ui.agent_chat_retry": "Reintentar", "ui.agent_chat_right": "Derecha", "ui.agent_chat_save": "Guardar", @@ -3873,12 +3921,17 @@ "ui.ai_copy_suffix": "copia", "ui.ai_custom_url": "URL personalizada", "ui.ai_default": "Predeterminado", + "ui.ai_edit": "Editar", + "ui.ai_edit_agent_title": "Editar agente", + "ui.ai_edit_gone": "Este agente se eliminó, así que no hay nada que guardar.", + "ui.ai_edit_tip": "Cambia el nombre, el comando o el endpoint de este agente. La clave guardada sigue oculta.", "ui.ai_http_busy": "el servicio está ocupado o ha superado su límite; inténtalo más tarde", "ui.ai_http_key": "el servicio ha rechazado la clave: compruébala en Preferencias > IA", "ui.ai_http_not_found": "no existe ese servicio o modelo: comprueba la URL base y el nombre del modelo", "ui.ai_http_too_large": "la conversación es demasiado grande para este endpoint; empieza un chat nuevo", "ui.ai_key": "Clave de API", "ui.ai_key_hint": "opcional; se guarda en el llavero del sistema", + "ui.ai_key_keep_hint": "déjalo vacío para conservar la clave guardada", "ui.ai_key_needs_https": "La clave solo se envía mediante HTTPS o a este ordenador.", "ui.ai_key_note": "Se guarda en el llavero del sistema, nunca en los ajustes de SolidCraft. Déjalo vacío para un servidor local.", "ui.ai_keychain_error": "El llavero del sistema ha rechazado la operación: {error}", @@ -3910,6 +3963,7 @@ "ui.ai_request_timeout": "Tiempo de espera de las peticiones", "ui.ai_request_timeout_hint": "Cuánto puede tardar una petición al modelo en esta API: de 30 s a una hora. Auméntalo para un modelo local lento.", "ui.ai_reset": "Restablecer", + "ui.ai_save": "Guardar", "ui.ai_settings": "Ajustes de IA…", "ui.ai_show_features": "Mostrar funciones de IA", "ui.ai_show_features_note": "Chat del agente: panel en el que un agente de programación que ya utilices, o un servicio de modelos que elijas, trabaja sobre el diseño abierto. Desactivado: no hay botón, atajo, entrada de menú ni panel de IA, y no se busca ni inicia nada.", @@ -4470,6 +4524,7 @@ "ui.properties.inertia_y": "Iyx, Iyy, Iyz", "ui.properties.inertia_z": "Izx, Izy, Izz", "ui.properties.measuring": "Midiendo…", + "ui.properties.measuring_bodies": "Midiendo… {done} de {total} cuerpos", "ui.properties.measuring_failed": "La medición se detuvo antes de terminar.", "ui.properties.principal_axis": "Eje principal {number}", "ui.properties.principal_moments": "Momentos principales", @@ -4537,6 +4592,7 @@ "ui.selectOther": "Seleccionar otro…", "ui.selectTangentChain": "Seleccionar cadena tangente", "ui.select_all": "Seleccionar todo", + "ui.select_components_by_face": "Seleccionar componentes por cara", "ui.selection": "Selección", "ui.selection_modifiers": "Modificadores de selección", "ui.selection_shift_add_only": "Mayús+clic añade sin quitar", @@ -4692,7 +4748,14 @@ "ui.viewport_status_key": "Mostrar tecla para {name}", "ui.viewport_status_minimum_radius": "Radio mínimo", "ui.viewport_status_pairs": "{count} pares", + "ui.viewport_status_section_hatch": "Sombreado de sección: {setting}", + "ui.viewport_status_section_plane": "Plano de sección", + "ui.viewport_status_section_tilted": "Sección inclinada ({offset})", "ui.viewport_status_stripes": "{count} franjas", + "ui.viewport_status_summary_clear": "Limpiar resumen", + "ui.viewport_status_summary_heading": "Encabezado del resumen", + "ui.viewport_status_summary_label": "Etiqueta del resumen: {name} ({count})", + "ui.viewport_status_summary_more": "Más del resumen ({count})", "ui.viewport_theme_follow": "Seguir tema", "ui.visual_shaded_hidden_edges": "Sombreado con aristas ocultas", "ui.visual_shaded_with_edges": "Sombreado con aristas", diff --git a/assets/locales/fi.json b/assets/locales/fi.json index 3197471228..142c0c90bb 100644 --- a/assets/locales/fi.json +++ b/assets/locales/fi.json @@ -2054,6 +2054,7 @@ "prefs.status_error": "Virhe", "prefs.status_selected": "Valittu", "prefs.status_warning": "Varoitus", + "prefs.structure.scale_percent": "{percent} %", "prefs.supersampling_brief": "Piirtää näkymän suurempana ja suodattaa sen pienemmäksi: erittäin terävä ja raskaampi.", "prefs.tab_accent_note": "Korostusväri seuraa aktiivista välilehteä", "prefs.theme_file": "teematiedosto", diff --git a/assets/locales/fr.json b/assets/locales/fr.json index 12b621c6f2..27ce2b4545 100644 --- a/assets/locales/fr.json +++ b/assets/locales/fr.json @@ -822,6 +822,7 @@ "cmd.appearance_pick_up": "Prélever l'apparence", "cmd.appearance_replace": "Remplacer l'apparence", "cmd.assembly_bom": "Nomenclature", + "cmd.assembly_mass_properties": "Propriétés de masse", "cmd.body.print_face": "Face d’impression", "cmd.body_rename": "Renommer le corps", "cmd.browser_collapse_all": "Tout réduire", @@ -1247,6 +1248,7 @@ "cmd.view.render_browser": "Navigateur de rendu", "cmd.view.render_dock": "Déplacer le panneau de rendu", "cmd.view.render_focus": "Vue de rendu ciblée", + "cmd.view.status_details": "Détails d'état de la vue 3D", "cmd.view.status_items": "Éléments d'état de la vue 3D", "cmd.view_ambient_occlusion": "Occlusion ambiante", "cmd.view_angle_readout": "Angles de vue", @@ -1285,8 +1287,14 @@ "color.hex_hint": "#rrggbb", "color.hue": "Teinte", "color.linear": "Valeurs de couleur affichées en espace linéaire 0-1", + "color.recent": "Couleurs récentes", + "color.recent_help": "Les couleurs choisies dans n'importe quel champ de couleur apparaissent ici", + "color.saturation": "Saturation (pour cent)", "color.saturation_value": "Saturation et valeur", "color.selected": "Couleur sélectionnée", + "color.step_down": "Diminuer d'un pas", + "color.step_up": "Augmenter d'un pas", + "color.value": "Valeur, la luminosité (pour cent)", "config.column_filter_empty": "Aucune colonne correspondante", "contact.components_count": "{count} composants", "contact.enabled_label": "activé", @@ -1333,6 +1341,7 @@ "dialog.dimension_name": "Cote : {name}", "dialog.edit_title": "MODIFIER {title}", "dialog.no_shown_body": "Aucun corps affiché à modifier : affichez-en un ou choisissez Nouveau corps", + "dialog.pattern_sketch_not_object": "Choisissez des corps ou des fonctions : une esquisse n'est ni l'un ni l'autre. Répétez ses courbes avec la Répétition ou la Symétrie de l'esquisse, ou extrudez-la d'abord.", "dialog.select": "↖ Sélectionner", "dialog.selected_count": "↖ {count} sélectionné(s)", "dialog.selection_guidance": "Sélectionner une entrée pour la modifier. Cliquer sur un objet sélectionné pour le retirer ; Ctrl/Cmd-clic le bascule aussi. Vérifier l'aperçu, puis appuyer sur OK.", @@ -1390,21 +1399,168 @@ "from the rule": "selon la règle", "help.apache": "Licence Apache 2.0", "help.appearance.assign": "Donne aux corps, faces ou composants sélectionnés une couleur ou l'apparence d'un matériau, sans changer leur poids. À utiliser pour distinguer des pièces ou prévisualiser une finition.", + "help.assembly.bom": "Liste les pièces de la conception ou d'un composant avec leurs quantités, par référence, et peut enregistrer la liste en CSV ou JSON. À utiliser comme nomenclature pour la commande et l'assemblage.", "help.attributions": "Attributions (ATTRIBUTION.md)", "help.build": "Version {version} · commit {commit} · compilée le {date}", + "help.canvas.decal": "Place une image sur une face plane du modèle, comme un autocollant. À utiliser pour afficher une étiquette, un logo ou un motif imprimé sur une pièce.", + "help.canvas.edit": "Modifie un canevas : sa position, sa taille, son angle, son opacité, sa symétrie, sa visibilité ou son nom. À utiliser pour aligner une image de référence sur l'esquisse que vous décalquez.", + "help.canvas.insert": "Place une image PNG ou JPEG sur un plan ou une face comme référence translucide. À utiliser pour décalquer une photo ou un dessin dans une esquisse, l'image étant mise à une échelle connue.", + "help.component.break_link": "Détache un composant lié de son fichier de conception en conservant ses corps tels quels. À utiliser lorsque la pièce ne doit plus suivre les modifications du fichier d'origine.", + "help.component.capture_position": "Conserve les positions des pièces que vous avez déplacées à la main, qui deviennent leur nouvelle position. À utiliser après avoir fait glisser en place des pièces qu'aucune liaison ne maintient.", + "help.component.create": "Ajoute un composant vide à la conception, dans le composant actif sauf si vous choisissez un autre parent. À utiliser pour donner à une nouvelle pièce d'un assemblage son propre espace pour les corps et les esquisses.", "help.component.finish_edit": "Enregistre la pièce que vous modifiez sur place, revient à son assemblage et met à jour l'assemblage à partir d'elle. À utiliser lorsque vos modifications de la pièce liée sont terminées.", + "help.component.from_bodies": "Transforme chaque corps sélectionné en composant à part entière, portant le nom du corps. À utiliser lorsqu'une conception faite de corps isolés doit devenir un assemblage dont les pièces peuvent être reliées et déplacées.", + "help.component.insert_design": "Insère une autre conception enregistrée comme composant lié à son fichier, avec ses corps solides placés comme dans ce fichier. À utiliser pour réutiliser une pièce dans un assemblage et récupérer les modifications ultérieures avec Mettre à jour les liens.", + "help.component.properties": "Affiche le volume, la masse, le centre de gravité et les moments d'inertie d'un composant, d'après les matériaux de ses corps. À utiliser pour vérifier le poids et l'équilibre d'un assemblage.", + "help.component.revert_position": "Remet à leur place les pièces que vous avez déplacées à la main, en abandonnant les déplacements non capturés. À utiliser pour annuler des essais de déplacement dans un assemblage.", + "help.component.update_links": "Relit les fichiers de conception liés et remplace les corps qui ont changé. À utiliser après avoir modifié une pièce utilisée par cet assemblage.", + "help.config.configure": "Ajoute des paramètres, des fonctions, des matériaux ou des apparences comme colonnes de la table des configurations. À utiliser pour créer des variantes d'une conception, par exemple les tailles d'une pièce.", + "help.config.preview": "Vérifie qu'une ligne de la table des configurations se reconstruit, sans modifier la conception ni son historique. À utiliser avant de basculer vers une variante incertaine.", + "help.config.rules": "Affiche les règles de la table des configurations, ses lignes et la ligne active. À utiliser pour voir comment les variantes d'une conception sont définies.", + "help.config.table": "Affiche la table des configurations : une ligne par variante, une colonne par paramètre ou fonction, et si la conception correspond toujours à la ligne active. À utiliser pour passer en revue les variantes d'une conception.", "help.connect_note": "Redémarrez chaque assistant pour charger le serveur SolidCraft. Chaque fichier a été sauvegardé en .bak avant d'être modifié.", "help.connect_title": "Connecter les assistants IA", + "help.construct.axis.edge": "Crée un axe de construction le long d'une arête droite ou d'une ligne d'esquisse. À utiliser comme axe pour une révolution, une répétition circulaire ou une liaison.", + "help.construct.axis.normal_to_face": "Crée un axe de construction passant par un point, perpendiculaire à une face. À utiliser comme axe qui sort droit d'une surface.", + "help.construct.axis.through_cylinder": "Crée un axe de construction passant par le centre d'une face cylindrique, conique ou torique, ou d'une arête circulaire. À utiliser pour faire tourner ou répéter des fonctions autour d'une pièce ronde.", + "help.construct.axis.two_planes": "Crée un axe de construction là où deux plans ou faces planes se rencontrent. À utiliser lorsque l'axe voulu est la droite entre deux faces.", + "help.construct.axis.two_points": "Crée un axe de construction passant par deux points, sommets ou points d'esquisse. À utiliser pour un axe qui relie deux positions connues.", + "help.construct.plane.along_path": "Crée un plan de construction perpendiculaire à une arête ou une courbe, à une position choisie le long de celle-ci. À utiliser pour esquisser le profil d'un balayage ou une coupe d'une trajectoire.", + "help.construct.plane.at_angle": "Crée un plan de construction tourné d'un angle par rapport à un plan ou une face, autour d'un axe ou d'une arête. À utiliser pour des esquisses inclinées, par exemple une équerre à angle.", + "help.construct.plane.midplane": "Crée un plan de construction à mi-distance entre deux plans ou faces planes, ou bissecteur lorsqu'ils se rencontrent en formant un angle. À utiliser comme plan de symétrie.", + "help.construct.plane.offset": "Crée un plan de construction parallèle à un plan ou une face plane, à une distance donnée ou passant par un point. À utiliser pour esquisser à une hauteur précise au-dessus d'une face.", + "help.construct.plane.perpendicular": "Crée un plan de construction qui contient une droite ou une arête et qui est perpendiculaire à un plan ou une face. À utiliser pour esquisser à la verticale sur un modèle, le long d'une arête.", + "help.construct.plane.tangent": "Crée un plan de construction qui touche un cylindre, un cône, une sphère ou un tore en un point. À utiliser pour esquisser sur le flanc d'une pièce ronde.", + "help.construct.plane.three_points": "Crée un plan de construction passant par trois points. À utiliser pour un plan défini par des positions du modèle, comme trois coins.", + "help.construct.plane.two_edges": "Crée un plan de construction passant par deux arêtes droites ou lignes situées dans un même plan. À utiliser pour un plan à travers une pièce, par exemple par deux arêtes opposées.", + "help.construct.point.along_path": "Crée un point de construction à une position donnée le long d'une arête ou d'une courbe. À utiliser comme référence à mi-chemin sur une trajectoire.", + "help.construct.point.center": "Crée un point de construction au centre d'une arête circulaire, d'un cercle ou arc d'esquisse, d'une sphère ou d'un tore. À utiliser comme référence pour des perçages, des axes et des liaisons.", + "help.construct.point.edge_plane": "Crée un point de construction là où une arête ou une ligne traverse un plan ou une face plane. À utiliser pour repérer où une trajectoire coupe un plan.", + "help.construct.point.three_planes": "Crée un point de construction là où trois plans ou faces planes se rencontrent. À utiliser comme référence de coin, même lorsque le modèle n'a pas de sommet à cet endroit.", + "help.construct.point.two_edges": "Crée un point de construction là où deux arêtes ou lignes se rencontrent, ou se rapprochent le plus. À utiliser comme référence à un coin virtuel.", + "help.construct.point.vertex": "Crée un point de construction sur un sommet ou un point d'esquisse. À utiliser comme référence qui subsiste lorsque l'esquisse ou le corps qui l'entoure est masqué.", + "help.contact.check": "Liste les paires de pièces des jeux de contact qui se traversent dans leur position actuelle. À utiliser pour trouver les collisions avant de piloter les liaisons ou de lancer une étude de mouvement.", + "help.contact.create": "Regroupe deux pièces ou plus dans un jeu de contact, dont les corps ne peuvent pas se traverser pendant le mouvement des liaisons. À utiliser pour des pièces qui doivent s'arrêter au contact, comme un levier contre sa butée.", + "help.contact.disable_all": "Désactive le contact : les liaisons pilotées et les études de mouvement ne s'arrêtent plus là où les pièces se touchent. À utiliser pour déplacer à nouveau les pièces librement, ou pour garder un grand assemblage réactif.", + "help.contact.edit": "Modifie un jeu de contact : les pièces qu'il contient, son nom, ou s'il est désactivé ; peut aussi supprimer le jeu. À utiliser pour ajuster les pièces qui ne doivent pas se traverser.", + "help.contact.enable_all": "Place toutes les pièces de l'assemblage dans un seul jeu de contact, de sorte qu'aucune paire de pièces ne puisse se traverser pendant le mouvement des liaisons. À utiliser pour une vérification rapide d'un mécanisme entier.", + "help.contact.enable_sets": "Active le contact pour les jeux de contact que vous avez définis, de sorte que les liaisons pilotées et les études de mouvement s'arrêtent là où leurs pièces se traverseraient. À utiliser pour retrouver vos jeux après avoir désactivé le contact.", + "help.contact.list": "Liste les jeux de contact de la conception, les pièces de chacun et si le contact est activé. À utiliser pour vérifier la configuration du contact avant de piloter l'assemblage.", + "help.document.inspect": "Affiche une vue d'ensemble de la conception : ses composants, corps, esquisses, fonctions et paramètres. À utiliser pour se repérer dans une conception que vous n'avez pas créée vous-même.", + "help.document.units": "Change les unités dans lesquelles la conception affiche les longueurs, par exemple mm ou pouces. La géométrie garde sa taille ; seuls l'affichage et les nombres bruts suivent.", + "help.edit.checkpoint": "Mémorise la conception, l'esquisse active et l'historique d'annulation dans leur état actuel. À utiliser avant un essai risqué dont vous voudrez peut-être revenir.", + "help.edit.restore_checkpoint": "Revient à un point de restauration, historique d'annulation compris. À utiliser pour abandonner en une seule étape tout ce qui a été fait depuis ce point.", + "help.explode.create": "Crée une vue éclatée qui écarte les pièces du centre de l'assemblage, ou selon les déplacements que vous définissez. À utiliser pour montrer comment un assemblage se monte, pour des notices ou des présentations.", "help.face.delete": "Supprime les faces sélectionnées et laisse les faces voisines refermer le vide, ou laisse le trou ouvert. À utiliser pour retirer un arrondi, un trou ou un bossage qui gêne.", + "help.file.insert_mesh": "Ajoute un fichier 3MF, STL, OBJ, PLY ou glTF à la conception sous forme de corps maillés dans un nouveau composant. À utiliser pour placer à côté de votre conception un modèle téléchargé ou numérisé comme référence.", + "help.file.insert_step": "Ajoute un fichier STEP, IGES, FreeCAD ou Rhino à la conception sous forme de corps solides dans un nouveau composant ; les pièces d'un assemblage deviennent des composants imbriqués. À utiliser pour importer des modèles de fournisseurs ou d'autres logiciels de CAO.", + "help.file.save_mesh": "Enregistre des corps en maillage STL, 3MF ou OBJ, avec la finesse des faces courbes que vous choisissez. À utiliser pour préparer des pièces à l'impression 3D ou pour des logiciels qui lisent des maillages.", + "help.file.send_to_slicer": "Enregistre les corps sélectionnés en fichier 3MF et l'ouvre dans le slicer de votre choix. À utiliser pour passer du modèle à l'impression en une seule étape.", "help.font_details": "L’interface utilise les polices intégrées d’egui (Ubuntu Light et Hack, sous les licences Ubuntu Font Licence et MIT/Bitstream Vera ; Noto Emoji et la police d’icônes emoji, sous OFL et MIT). Le texte des esquisses utilise Liberation Sans (SIL OFL 1.1). Le pack de polices facultatif de The Crafting Apps, craft-fonts (BIZ UDPGothic, Shippori Mincho, BIZ UDMincho, Noto Sans CJK SC, Noto Sans Arabic ; SIL OFL 1.1), n’est pas inclus dans les versions de SolidCraft.", "help.fonts": "Polices", + "help.frame.member": "Place un profilé, tel qu'un tube ou un profilé d'aluminium, le long des lignes d'une esquisse de structure, un élément par ligne. À utiliser pour construire des châssis, des racks et des structures soudées.", + "help.inspect.accessibility": "Colore les faces qu'un outil peut atteindre depuis une direction, et celles qu'il ne peut pas atteindre. À utiliser pour vérifier l'usinabilité ou le moulage d'une pièce depuis ce côté.", + "help.inspect.center_of_mass": "Affiche le centre de gravité des corps, de chacun séparément et de l'ensemble, à densité uniforme. À utiliser pour vérifier l'équilibre et l'endroit où une pièce basculera.", + "help.inspect.colored_components": "Donne à chaque composant sa propre couleur temporaire, sans modifier les apparences. À utiliser pour distinguer d'un coup d'œil les pièces d'un assemblage.", + "help.inspect.compare": "Compare deux corps et montre où l'un a de la matière que l'autre n'a pas, avec le volume et l'écart. À utiliser pour comparer une pièce modifiée ou importée à l'original.", + "help.inspect.continuity": "Vérifie la douceur de raccord des faces le long de leurs arêtes : jeux, cassures ou sauts de courbure, et colore les raccords. À utiliser pour trouver où des surfaces ne se raccordent pas proprement.", + "help.inspect.curvature_comb": "Trace des peignes le long de courbes, d'arêtes ou de faces, dont les dents montrent l'intensité de la courbure. À utiliser pour repérer les méplats, les cassures et les courbures irrégulières.", + "help.inspect.curvature_map": "Colore les faces selon l'intensité de leur courbure, en rouge là où le rayon est petit. À utiliser pour trouver les zones serrées et juger la douceur d'une surface.", + "help.inspect.draft": "Colore les faces selon leur angle de dépouille par rapport à une direction de démoulage, et signale celles qui en manquent. À utiliser pour vérifier une pièce avant moulage ou coulée.", + "help.inspect.environment_map": "Affiche le modèle avec une finition réfléchissante, de type miroir. À utiliser pour juger la fluidité des surfaces d'après la façon dont les reflets les parcourent.", + "help.inspect.interference": "Trouve les corps qui se chevauchent et montre chaque chevauchement avec son volume. À utiliser pour vérifier qu'aucune pièce d'un assemblage n'entre en collision.", + "help.inspect.isocurve": "Trace les courbes isoparamétriques des faces, c'est-à-dire la grille sur laquelle la surface est construite. À utiliser pour voir comment une surface s'écoule et où elle se resserre.", + "help.inspect.measure": "Mesure des longueurs, distances, angles, aires et rayons entre les éléments choisis, ou le volume et la masse de corps. À utiliser pour vérifier des dimensions et des jeux.", + "help.inspect.minimum_radius": "Trouve le plus petit rayon de courbure de courbes, d'arêtes ou de faces, et son emplacement. À utiliser pour vérifier qu'un outil ou une buse peut suivre la forme.", + "help.inspect.section": "Ouvre la vue selon un plan de coupe et hachure les faces coupées, sans modifier le modèle. À utiliser pour regarder à l'intérieur des pièces et des assemblages.", + "help.inspect.zebra": "Affiche des bandes noires et blanches réfléchies sur les faces du modèle. À utiliser pour juger la douceur : des bandes qui se cassent ou se brisent sur une arête révèlent une mauvaise transition.", + "help.joint.as_built": "Relie deux pièces là où elles se trouvent déjà, sans déplacer aucune des deux, et définit comment elles peuvent encore bouger. À utiliser pour des pièces déjà placées dans leur position finale.", + "help.joint.cam": "Relie une came tournante à un suiveur, qui glisse ou pivote lorsque le contour de la came passe dessous. À utiliser pour des cames, des excentriques et des commandes de soupapes.", + "help.joint.create": "Relie deux pièces aux origines de liaison que vous choisissez et déplace la seconde pièce en place ; le type de liaison définit comment elles peuvent encore bouger. À utiliser pour construire un assemblage dont les pièces se déplacent comme le vrai mécanisme.", + "help.joint.drive": "Définit l'angle ou la distance d'une liaison et déplace les pièces en conséquence, dans ses limites ; les liaisons couplées et les boucles fermées suivent. À utiliser pour vérifier qu'un mécanisme se déplace comme prévu.", + "help.joint.gear": "Couple deux liaisons rotatives avec un rapport d'engrenage, de sorte que piloter l'une fait tourner l'autre. À utiliser pour des engrenages droits et des couronnes, avec le rapport tiré des rayons primitifs ou des nombres de dents.", + "help.joint.limits": "Définit jusqu'où une liaison peut tourner ou coulisser, par un minimum et un maximum, ou supprime ces limites. À utiliser pour garder une charnière, une glissière ou un levier dans sa course réelle.", + "help.joint.motion_link": "Couple deux liaisons avec un rapport et un décalage, de sorte que piloter l'une déplace l'autre. À utiliser pour une relation fixe entre liaisons que Engrenage, Pignon et crémaillère ou Vis ne couvrent pas.", + "help.joint.origin": "Place sur une pièce une origine de liaison nommée : au centre d'une face ou d'une extrémité ronde, au milieu d'une arête, à un coin ou en un point. À utiliser pour préparer l'emplacement des liaisons ; une origine choisie sur une face la suit lorsque la pièce change de taille.", + "help.joint.rack_pinion": "Relie un pignon tournant à une crémaillère coulissante, de sorte que la crémaillère avance de la longueur de cercle primitif que le pignon parcourt en roulant. À utiliser pour des commandes à crémaillère, des directions et des axes linéaires.", + "help.joint.reach": "Déplace un point d'une pièce vers une cible en modifiant les liaisons de sa chaîne, dans leurs limites. À utiliser pour positionner un mécanisme selon l'endroit où doit se trouver son extrémité, comme le bout d'un bras de robot.", + "help.joint.rigid_group": "Verrouille ensemble les pièces sélectionnées telles qu'elles sont, pour qu'elles se déplacent d'un seul bloc. À utiliser pour des pièces qui vont ensemble et qui n'ont aucun mouvement entre elles.", + "help.joint.screw": "Couple rotation et translation par un pas, de sorte que chaque tour avance de cette distance. À utiliser pour des vis mères, des boulons et des broches filetées.", + "help.library.apply": "Applique un matériau ou une apparence d'un fichier de bibliothèque local à des corps, des faces ou des composants. À utiliser pour réutiliser des matériaux enregistrés depuis une autre conception.", + "help.library.export": "Enregistre les matériaux et apparences de la conception dans un fichier de bibliothèque. À utiliser pour les partager ou les réutiliser dans d'autres conceptions.", + "help.library.inspect": "Vérifie un fichier de bibliothèque et liste ses matériaux et apparences, sans modifier la conception. À utiliser pour voir ce que contient une bibliothèque avant de l'appliquer.", "help.licences": "Licences", "help.material.assign": "Applique le matériau choisi aux corps sélectionnés, par exemple acier, aluminium ou ABS. À utiliser pour que la masse et le poids correspondent à la pièce réelle.", + "help.material.properties.validate": "Vérifie qu'une fiche de données de matériau a des valeurs cohérentes et qu'aucune n'est manquante. À utiliser avant de se fier aux données pour la masse ou la rigidité.", + "help.mesh.convert": "Reconstruit un corps maillé, tel qu'un STL, en solide modifiable avec des faces planes et cylindriques. À utiliser pour modifier une pièce mécanique importée comme un corps modélisé.", "help.mit": "Licence MIT", + "help.motion.study": "Définit des valeurs de liaison à des étapes le long d'une chronologie et les rejoue sous forme d'animation. À utiliser pour voir un mécanisme se déplacer selon une séquence et vérifier les jeux en chemin.", + "help.motion.trace": "Trace le chemin que suit un point d'une pièce pendant la lecture d'une étude de mouvement ou le pilotage d'une liaison, et le conserve comme courbe d'esquisse 3D lorsque vous confirmez. À utiliser pour voir où arrive un mécanisme, ou pour construire à partir de sa trajectoire.", "help.notice": "NOTICE", + "help.occurrence.ground": "Fixe une pièce en place, ou la libère, de sorte que les liaisons déplacent les autres pièces autour d'elle. À utiliser pour le châssis ou la base sur laquelle le reste de l'assemblage est construit.", "help.occurrence.move": "Déplace une pièce d’assemblage selon les seules libertés permises par ses liaisons. Sélectionnez une face de la pièce, puis faites glisser une flèche ou un arc disponible.", "help.parameters.change": "Ouvre le tableau des valeurs nommées et des expressions qui pilotent les cotes de la conception. À utiliser pour changer une dimension à un seul endroit et faire suivre toutes les fonctions qui l'utilisent.", + "help.parameters.export": "Enregistre les paramètres de la conception en CSV ou JSON. À utiliser pour les modifier dans un tableur ou les transférer vers une autre conception.", + "help.parameters.graph": "Montre quels paramètres et fonctions en utilisent quels autres, ainsi que les boucles ou erreurs entre eux. À utiliser pour voir ce que la modification d'un paramètre va affecter.", + "help.parameters.import": "Lit des paramètres depuis un CSV ou un JSON dans la conception, tous ou aucun. À utiliser pour importer des valeurs d'un tableur ou d'une autre conception.", + "help.parameters.move": "Déplace un paramètre utilisateur avant ou après un autre dans la liste. À utiliser pour garder ensemble les paramètres liés.", + "help.parts.fastener": "Ajoute une vis, un boulon, un écrou, une rondelle, une goupille ou un roulement standard à la taille choisie. Placé sur un perçage, il s'y loge avec une liaison, ce qui évite de modéliser soi-même les fixations.", + "help.parts.insert": "Insère une pièce standard de la famille, de la taille et de la longueur choisies. Placée sur le bord ou la paroi d'un perçage, elle s'y loge avec une liaison rigide.", + "help.parts.library": "Ouvre les pièces standard : vis, boulons, écrous, rondelles, vis sans tête, goupilles cylindriques et roulements en tailles normalisées. À utiliser pour ajouter une pièce standard plutôt que de la modéliser soi-même.", + "help.parts.resize": "Change la taille ou la longueur d'une pièce standard insérée et la reconstruit ; ses liaisons la maintiennent en place. À utiliser lorsqu'une vis se révèle trop longue ou qu'un perçage demande une autre taille.", + "help.plastic.assign_rule": "Affecte une règle plastique à des corps, de sorte que leurs fonctions plastique prennent sa dépouille et son jeu par défaut. À utiliser pour configurer une pièce moulée une fois pour toutes ses fonctions.", + "help.plastic.boss": "Ajoute un bossage de vis sur une face : un plot avec perçage, dépouille, congé de pied et nervures de soutien facultatifs. À utiliser pour les points de vissage des boîtiers moulés ou imprimés.", + "help.plastic.lip": "Ajoute une lèvre ou une rainure correspondante le long du bord d'un corps évidé. À utiliser pour que deux moitiés de boîtier se positionnent l'une sur l'autre et se ferment hermétiquement.", + "help.plastic.manage_rules": "Crée et modifie des règles plastique : matériau, épaisseur de paroi, rayons, jeu et dépouille. À utiliser pour garder cohérentes les fonctions d'une pièce moulée.", + "help.plastic.rest": "Ajoute un appui sur une face : un plot rond ou rectangulaire, plein ou creux, avec dépouille. À utiliser comme support ou entretoise dans un boîtier moulé.", + "help.plastic.snap_fit": "Ajoute un crochet de clip sur une face, avec sa longueur, son épaisseur et son ergot. À utiliser pour assembler des pièces par encliquetage sans vis.", "help.pointcloud.insert": "Insère un scan PLY, XYZ ou PTS sous forme de nuage de points à côté du modèle. Utilisez-le comme référence pour construire un modèle à partir de données mesurées.", + "help.sheet.convert": "Transforme une plaque ou un corps évidé d'épaisseur uniforme en corps de tôlerie, avec des plis aux arêtes choisies. À utiliser pour rendre une pièce existante dépliable.", + "help.sheet.export_bend_table": "Enregistre la table des plis d'un corps de tôlerie en CSV ou JSON : angles, rayons et positions des plis à plat. À utiliser pour la plieuse ou l'atelier.", + "help.sheet.export_dxf": "Enregistre la mise à plat en DXF, avec contours, lignes de pli et annotations de pli facultatives. À utiliser pour la découpe laser, plasma ou jet d'eau.", + "help.sheet.flange": "Ajoute un bord tombé : une plaque de base à partir d'une esquisse, une paroi le long d'un contour d'esquisse ouvert, ou une paroi pliée sur des arêtes de tôle. À utiliser pour construire des pièces de tôlerie.", + "help.sheet.flat_pattern": "Crée la mise à plat d'un corps de tôlerie, avec sa taille, ses découpes et ses lignes de pli. À utiliser pour vérifier le flan avant de le découper.", + "help.sheet.fold": "Plie une tôle le long d'une ligne d'esquisse selon un angle et un rayon. À utiliser pour plier une tôle plate après y avoir découpé des fonctions.", + "help.sheet.hem": "Replie un bord de tôle sur lui-même. À utiliser pour obtenir des bords sûrs et rigides, sans bavures coupantes.", + "help.sheet.manage_rules": "Crée et modifie des règles de tôlerie : épaisseur, rayon de pli, facteur K, dégagements et jeux. À utiliser pour s'adapter au matériau et aux outils de votre atelier.", + "help.sheet.path_flange": "Ajoute une chaîne de bords tombés le long d'une arête de tôle, en suivant une chaîne ouverte de lignes d'esquisse. À utiliser pour des profils en U ou en Z en une seule étape.", + "help.sheet.perforate": "Ajoute un champ de trous sur toute la tôle à plat, en motif décalé ou carré. À utiliser pour des aérations, des grilles et des panneaux allégés.", + "help.sheet.refold": "Replie un corps de tôlerie déplié. À utiliser après avoir ajouté des fonctions à la tôle à plat.", + "help.sheet.unfold": "Déplie tous les plis d'un corps de tôlerie. À utiliser pour ajouter des trous ou des découpes qui doivent traverser un pli, puis replier.", + "help.sketch.arc.center_point": "Dessine un arc de cercle autour d'un centre, d'un point de départ à un point d'arrivée. À utiliser lorsque le centre de l'arc est connu, comme un rayon autour d'un perçage ou d'un pivot.", + "help.sketch.arc.tangent": "Dessine un arc qui part de l'extrémité d'une ligne ou d'un arc et continue en tangence avec lui. À utiliser pour poursuivre une chaîne avec une courbe douce plutôt qu'un angle.", + "help.sketch.arc.three_point": "Dessine un arc d'un point de départ à un point d'arrivée en passant par un troisième point. À utiliser lorsque l'arc doit passer par des points donnés plutôt qu'autour d'un centre connu.", + "help.sketch.arc3d": "Dessine un arc de cercle exact dans l'espace par trois points, avec Esquisse 3D activée. À utiliser pour des courbes de trajectoires et de rails guides qui sortent du plan d'esquisse.", + "help.sketch.auto_constrain": "Ajoute les contraintes que l'esquisse a presque déjà, comme horizontale, parallèle ou coïncidente, puis la cote jusqu'à ce qu'elle soit entièrement définie. À utiliser pour verrouiller en une étape une esquisse dessinée approximativement.", + "help.sketch.auto_constrain_from_datum": "Fonctionne comme Contraintes automatiques, mais mesure les positions depuis un point de référence que vous choisissez plutôt que depuis l'origine. À utiliser lorsque la pièce est tracée à partir d'un point de référence, comme un coin ou un perçage.", + "help.sketch.blend_curve": "Relie les extrémités de deux courbes par une spline douce, tangente aux deux, ou aussi continue en courbure si vous le choisissez. À utiliser pour combler un écart entre des courbes sans cassure.", + "help.sketch.break": "Scinde une courbe en morceaux là où d'autres courbes la croisent, ou en des points que vous placez dessus. À utiliser lorsqu'une partie d'une courbe a besoin de sa propre contrainte ou doit être supprimée seule.", + "help.sketch.cancel": "Quitte l'esquisse et abandonne tout ce qui a été modifié depuis sa création ou son ouverture pour modification. Annuler rétablit les modifications.", + "help.sketch.centerline": "Bascule des lignes entre lignes normales et lignes d'axe, des axes que les profils ignorent. Utilisez une ligne d'axe comme axe pour une révolution, une symétrie ou une contrainte de symétrie.", + "help.sketch.chamfer.distance_angle": "Biseaute le coin entre deux lignes, défini par une distance le long de la première ligne et un angle. À utiliser lorsqu'un dessin donne le chanfrein sous forme de longueur et d'angle, comme 2 mm × 30°.", + "help.sketch.chamfer.equal_distance": "Biseaute le coin entre deux lignes sur la même distance le long des deux. À utiliser pour le chanfrein habituel à 45° sur un contour.", + "help.sketch.chamfer.two_distance": "Biseaute le coin entre deux lignes, défini par une distance distincte le long de chaque ligne. À utiliser pour des chanfreins qui ne sont pas à 45°, donnés sous forme de deux longueurs.", + "help.sketch.circle.center": "Dessine un cercle à partir de son centre et de son diamètre. À utiliser pour des perçages, des bossages et d'autres formes rondes dont le centre est connu.", + "help.sketch.circle.three_point": "Dessine un cercle passant par trois points. À utiliser lorsque le cercle doit passer par des points donnés, par exemple de la géométrie existante.", + "help.sketch.circle.three_tangent": "Dessine un cercle qui touche trois lignes, cercles ou arcs. À utiliser pour inscrire un cercle dans l'espace entre trois courbes, comme le cercle inscrit d'un triangle.", + "help.sketch.circle.two_point": "Dessine un cercle à partir des deux extrémités de son diamètre. À utiliser lorsque les points du bord du cercle sont connus plutôt que son centre.", + "help.sketch.circle.two_tangent": "Dessine un cercle de taille donnée qui touche deux lignes, cercles ou arcs. À utiliser pour un arrondi de rayon fixe entre deux courbes.", + "help.sketch.conic": "Dessine une courbe conique entre deux extrémités, façonnée par un point sommet et une valeur rho. À utiliser pour des transitions douces dont vous voulez ajuster la tension, de presque plate à presque anguleuse.", + "help.sketch.constrainer": "Applique la contrainte qu'une ou deux courbes choisies ont presque déjà, comme horizontale, parallèle, tangente ou égale. À utiliser pour mettre au propre une esquisse à main levée, une relation à la fois.", + "help.sketch.constraint.coincident": "Place un point sur un autre point, une courbe ou un axe d'esquisse. À utiliser pour fermer les écarts d'un profil ou pour poser un point sur une ligne.", + "help.sketch.constraint.collinear": "Place deux lignes sur une même droite. À utiliser pour aligner des arêtes interrompues, comme les deux côtés d'une encoche.", + "help.sketch.constraint.concentric": "Donne le même centre à deux cercles ou arcs. À utiliser pour des bagues, des lamages et des arbres à épaulements.", + "help.sketch.constraint.curvature": "Fait que deux courbes qui se rejoignent en une extrémité continuent avec la même courbure (G2). À utiliser lorsqu'un raccord tangent n'est pas assez doux, par exemple sur des surfaces visibles.", + "help.sketch.constraint.equal": "Donne la même longueur à des lignes, ou le même rayon à des cercles et arcs. À utiliser pour qu'une seule cote pilote plusieurs formes identiques.", + "help.sketch.constraint.fix": "Fixe des points ou des courbes là où ils sont, ou les libère. À utiliser pour la géométrie qui ne doit pas bouger lorsque vous faites glisser ou résolvez l'esquisse.", + "help.sketch.constraint.horizontal_vertical": "Rend une ligne, ou la ligne entre deux points, horizontale ou verticale, selon la plus proche. À utiliser pour redresser un contour sur les axes de l'esquisse.", + "help.sketch.constraint.midpoint": "Place un point au milieu d'une ligne, ou fait coïncider les milieux de deux lignes. À utiliser pour centrer de la géométrie sur une arête.", + "help.sketch.constraint.parallel": "Rend deux lignes parallèles. À utiliser pour des côtés qui doivent rester parallèles lorsque d'autres cotes changent.", + "help.sketch.constraint.perpendicular": "Fait se rencontrer deux lignes à angle droit. À utiliser pour des coins d'équerre entre des lignes qui ne sont pas sur les axes de l'esquisse.", + "help.sketch.constraint.polygon": "Transforme une chaîne fermée de lignes en polygone régulier : côtés égaux, avec tous les coins sur un même cercle. À utiliser pour rendre régulier un polygone dessiné à main levée et le garder ainsi.", + "help.sketch.constraint.symmetry": "Fait que deux points ou courbes sont symétriques par rapport à une ligne ou à un point. À utiliser pour garder un profil symétrique lorsque vous modifiez un côté.", + "help.sketch.constraint.tangent": "Fait que deux courbes se touchent en tangence, comme une ligne et un arc. À utiliser pour des raccords doux sans cassure.", + "help.sketch.construction": "Bascule des courbes entre géométrie normale et géométrie de construction, que les profils ignorent. Utilisez les courbes de construction comme aides pour l'implantation, les références et les cotes.", "help.sketch.construction_line": "Crée une droite de construction infinie passant par deux points. À utiliser pour structurer une esquisse sans diviser les profils.", "help.sketch.construction_line.angle": "Crée une droite de construction infinie inclinée d'un angle par rapport à l'axe X de l'esquisse. Choisissez le point puis saisissez l'angle, ou choisissez sa direction.", "help.sketch.construction_line.bisector": "Sélectionnez deux lignes droites sécantes de l'esquisse, puis choisissez l'une des deux bissectrices. Le résultat suit les lignes dont il provient.", @@ -1415,11 +1571,71 @@ "help.sketch.construction_line.tangent_external": "Sélectionnez un cercle ou un arc de cercle, puis un point extérieur et une solution de tangente. Les aperçus n'affichent que les points de contact situés sur l'arc dessiné.", "help.sketch.construction_line.tangent_two": "Sélectionnez deux cercles ou arcs de cercle, puis choisissez une tangente commune dans l'aperçu. Les points de contact doivent se trouver sur les courbes dessinées.", "help.sketch.construction_line.vertical": "Crée une droite de construction verticale infinie passant par un point. Sa contrainte verticale est conservée lors des modifications ultérieures.", + "help.sketch.copy": "Copie les courbes et points d'esquisse sélectionnés, avec les contraintes et cotes qui les relient. Collez-les dans cette esquisse ou dans une autre.", + "help.sketch.create": "Démarre une nouvelle esquisse 2D sur un plan ou sur une face plane du modèle. Toute extrusion, révolution et balayage commence par une esquisse.", + "help.sketch.cut": "Copie la géométrie d'esquisse sélectionnée et la supprime de l'esquisse, en une seule étape d'annulation. Collez-la ailleurs dans cette esquisse ou dans une autre.", + "help.sketch.dimension": "Ajoute une cote pilotante : une longueur, une distance, un angle, un rayon ou un diamètre, selon ce que vous choisissez. Saisissez une valeur ou un nom de paramètre, et l'esquisse la suit.", + "help.sketch.edit": "Ouvre une esquisse existante pour la modifier. Les fonctions construites dessus se mettent à jour lorsque vous terminez l'esquisse.", + "help.sketch.ellipse": "Dessine une ellipse à partir de son centre, de l'extrémité de son grand axe et d'un point côté petit axe. À utiliser pour des ouvertures ovales, des cames et d'autres formes rondes allongées.", "help.sketch.equation_curve": "Dessine une courbe à partir de formules : x(t) et y(t), ou y = f(x), sur un intervalle. Les formules peuvent utiliser des paramètres et la courbe les suit : à utiliser pour une sinusoïde, une spirale ou la développante d'une dent d'engrenage.", + "help.sketch.extend": "Prolonge une ligne ou un arc jusqu'à la courbe la plus proche sur son chemin, ou fait croître deux courbes jusqu'à ce qu'elles se rejoignent. À utiliser pour fermer des jeux dans un profil après ajustement ou modification.", + "help.sketch.fillet": "Arrondit le coin où deux lignes ou arcs se rejoignent par un arc de rayon donné. Plusieurs coins arrondis ensemble partagent un même rayon.", + "help.sketch.finish": "Quitte l'esquisse et revient au modèle. Ses profils fermés sont alors prêts pour Extrusion, Révolution et d'autres fonctions.", + "help.sketch.finish_auto_constrain": "Lance Contraintes automatiques sur l'esquisse, puis la termine. À utiliser pour laisser une esquisse entièrement définie en une seule étape.", + "help.sketch.fit_curves_to_section": "Coupe un corps maillé ou solide avec le plan d'esquisse et ajuste des lignes et des arcs à la section. À utiliser pour reconstruire une pièce numérisée ou importée sous forme d'esquisse modifiable.", + "help.sketch.include_3d": "Amène des arêtes du modèle, des sommets et des courbes d'autres esquisses dans cette esquisse comme références liées, en aplatissant sur son plan celles qui en sont hors. À utiliser pour construire à partir d'une géométrie qui n'est pas dans le plan d'esquisse.", + "help.sketch.infinite": "Bascule des lignes entre lignes normales et lignes de construction infinies qui traversent toute la vue. À utiliser comme références d'implantation qui ne s'arrêtent jamais trop court.", + "help.sketch.insert_dxf": "Lit les courbes d'un dessin DXF dans une esquisse, mises à l'échelle, tournées et placées comme vous le définissez. À utiliser pour construire à partir d'un dessin 2D d'un autre logiciel, comme un contour de découpe laser.", + "help.sketch.insert_svg": "Lit les tracés d'un dessin SVG dans une esquisse, mis à l'échelle, tournés et placés comme vous le définissez. À utiliser pour transformer un logo ou un contour vectoriel en courbes que vous pouvez extruder.", + "help.sketch.inspect": "Ouvre un rapport sur l'esquisse ouverte : combien de degrés de liberté il reste et si elle est entièrement contrainte. À utiliser pour savoir ce qui bouge encore avant de terminer l'esquisse.", + "help.sketch.intersect": "Ajoute les courbes ou points où des corps, faces, arêtes ou d'autres esquisses traversent le plan d'esquisse. Ils restent liés et suivent le modèle lorsqu'il change.", + "help.sketch.intersection_curve": "Crée des courbes 3D là où deux corps ou faces se rencontrent, liées à eux. À utiliser comme trajectoires ou rails guides qui suivent le modèle.", + "help.sketch.isoparametric_curve": "Crée une courbe 3D le long de la direction U ou V d'une face, par un point que vous choisissez, liée à la face. À utiliser comme trajectoire ou ligne de partage qui suit la surface.", + "help.sketch.line": "Dessine des lignes connectées point par point, en chaîne ouverte ou en contour fermé. C'est l'outil de base de la plupart des profils.", + "help.sketch.line.midpoint": "Dessine une ligne de son milieu vers une extrémité, l'autre extrémité étant son symétrique. À utiliser pour des lignes centrées sur un point, comme une arête symétrique.", + "help.sketch.line3d": "Dessine des lignes droites connectées entre des points de l'espace, avec Esquisse 3D activée. À utiliser pour des trajectoires et des structures qui sortent du plan d'esquisse.", + "help.sketch.mirror": "Symétrise des courbes et points d'esquisse par rapport à une ligne ou un axe et ajoute des contraintes de symétrie. À utiliser pour dessiner la moitié d'un profil symétrique et le compléter.", + "help.sketch.move": "Déplace ou fait tourner des courbes et points d'esquisse, ou une copie. À utiliser pour repositionner de la géométrie en gardant sa forme et ses contraintes.", + "help.sketch.offset": "Crée une copie d'une chaîne de courbes à une distance donnée, d'un côté ou des deux. À utiliser pour des parois, des contours et des jeux ; la distance reste une cote modifiable.", + "help.sketch.paste": "Colle dans l'esquisse active la géométrie d'esquisse copiée ou coupée. Placez-la en un point ou décalez-la d'une valeur.", + "help.sketch.pattern.circular": "Copie la géométrie d'esquisse autour d'un centre, sur un cercle complet ou un angle donné. À utiliser pour des cercles de perçages, des rayons et des dents.", + "help.sketch.pattern.rectangular": "Copie la géométrie d'esquisse en lignes et colonnes à des espacements donnés. À utiliser pour des grilles de trous, de rainures ou d'aérations.", + "help.sketch.point": "Place un point d'esquisse. À utiliser comme référence pour des cotes, des contraintes, des centres de perçage ou des fonctions ultérieures.", + "help.sketch.point3d": "Place un point dans l'espace, avec Esquisse 3D activée. À utiliser comme référence pour des courbes et trajectoires 3D.", + "help.sketch.polygon.circumscribed": "Dessine un polygone régulier autour d'un cercle, dimensionné du centre au milieu d'un côté. À utiliser lorsque la largeur sur plats est connue, comme pour un écrou hexagonal.", + "help.sketch.polygon.edge": "Dessine un polygone régulier à partir des deux extrémités d'un côté. À utiliser lorsque la longueur et la position d'un côté sont connues.", + "help.sketch.polygon.inscribed": "Dessine un polygone régulier à l'intérieur d'un cercle, dimensionné du centre à un coin. À utiliser lorsque la dimension sur coins est connue.", + "help.sketch.project": "Amène des arêtes du modèle, des faces, des sommets et des courbes d'autres esquisses dans cette esquisse, projetés sur son plan. Ils restent liés et suivent le modèle lorsqu'il change.", + "help.sketch.project_to_surface": "Projette des courbes d'autres esquisses selon la normale de cette esquisse sur une face, sous forme de courbes 3D liées. À utiliser pour poser un logo, une ligne de partage ou une trajectoire sur une surface courbe.", + "help.sketch.rectangle.center": "Dessine un rectangle à partir de son centre et d'un coin. À utiliser pour des contours centrés sur un point, comme l'origine.", + "help.sketch.rectangle.rounded": "Dessine un rectangle dont les quatre coins sont arrondis au même rayon. À utiliser pour des plaques, des couvercles et des ouvertures à coins arrondis.", + "help.sketch.rectangle.three_point": "Dessine un rectangle à partir d'un côté et d'un point du côté opposé. À utiliser pour des rectangles inclinés par rapport aux axes de l'esquisse.", + "help.sketch.rectangle.two_point": "Dessine un rectangle à partir de deux coins opposés. À utiliser pour les contours de plaques, de blocs et de poches.", + "help.sketch.scale": "Met à l'échelle des courbes et points d'esquisse selon un facteur autour d'un point de base. À utiliser pour redimensionner une partie d'une esquisse sans la redessiner.", + "help.sketch.slot.arc_center": "Dessine une rainure courbe de largeur donnée le long d'un arc défini par son centre, son début et sa fin. À utiliser pour des rainures de réglage autour d'un pivot.", + "help.sketch.slot.arc_three_point": "Dessine une rainure courbe de largeur donnée le long d'un arc passant par trois points. À utiliser lorsque le tracé de la rainure doit passer par des points donnés.", + "help.sketch.slot.center_point": "Dessine une rainure droite de largeur donnée de son centre au centre d'un arc d'extrémité. À utiliser pour des rainures centrées sur un point.", + "help.sketch.slot.center_to_center": "Dessine une rainure droite de largeur donnée entre les centres de ses deux arcs d'extrémité. À utiliser pour des rainures cotées de centre à centre, comme les rainures de réglage de vis.", + "help.sketch.slot.overall": "Dessine une rainure droite de largeur donnée d'une extrémité extérieure à l'autre. À utiliser lorsque c'est la longueur hors tout de la rainure qui compte.", + "help.sketch.spline.control_point": "Dessine une spline cubique façonnée par des points de contrôle qu'elle ne traverse pas. À utiliser pour des courbes douces et fluides que vous façonnez en déplaçant les points.", + "help.sketch.spline.control_point_5": "Dessine une spline de degré 5 façonnée par des points de contrôle. À utiliser lorsque des courbes doivent se raccorder avec une courbure plus douce, par exemple sur des surfaces de style visibles.", + "help.sketch.spline.fit_point": "Dessine une spline douce qui passe par chaque point que vous placez, ouverte ou fermée. À utiliser pour décalquer une forme à travers des points connus.", + "help.sketch.spline3d": "Dessine une courbe douce dans l'espace par des points, avec Esquisse 3D activée. À utiliser pour des trajectoires de balayage et des cheminements de câbles qui sortent du plan d'esquisse.", + "help.sketch.spun_profile": "Crée dans le plan d'esquisse le contour qu'un corps balaie en tournant autour d'un axe. À utiliser pour obtenir la silhouette d'une pièce tournée pour une révolution ou un dessin.", + "help.sketch.text": "Écrit du texte dans l'esquisse avec une police, une hauteur et un angle au choix ; ses mots peuvent suivre des paramètres. À extruder ou embosser pour des étiquettes et des lettrages.", + "help.sketch.transform": "Met à l'échelle, fait tourner et déplace de la géométrie d'esquisse autour d'un centre en une seule étape. À utiliser pour repositionner et redimensionner une sélection d'un coup.", + "help.sketch.trim": "Supprime le morceau d'une courbe entre les croisements les plus proches autour de l'endroit où vous cliquez. À utiliser pour nettoyer des lignes qui se chevauchent et obtenir un profil fermé.", "help.solid.align": "Déplace les corps sélectionnés pour qu'un de leurs points, faces ou plans rejoigne un point, une face ou un plan ailleurs. À utiliser pour poser une pièce contre une autre sans mesurer le décalage.", + "help.solid.boundary_fill": "Crée des solides à partir des régions délimitées par des corps outils qui se chevauchent, en ne remplissant que les régions que vous choisissez. À utiliser pour construire une forme à partir de l'espace que plusieurs corps délimitent ensemble.", + "help.solid.bounding_solid": "Crée un parallélépipède qui englobe les corps sélectionnés, avec une marge facultative. À utiliser pour voir l'encombrement d'une pièce, ou comme brut dans lequel elle est usinée.", + "help.solid.box": "Crée un parallélépipède à partir d'une longueur, d'une largeur et d'une hauteur, depuis un coin ou centré sur un point. À utiliser pour un bloc rapide, un volume provisoire ou un corps dans lequel tailler des fonctions.", "help.solid.chamfer": "Coupe les arêtes sélectionnées par un biseau plat de la distance indiquée. À utiliser pour casser des arêtes vives ou faciliter l'entrée d'une goupille ou d'une vis.", + "help.solid.coil": "Crée un corps hélicoïdal, comme un ressort, à partir d'un diamètre, d'un nombre de tours, d'une hauteur ou d'un pas et d'une section ronde ou carrée. À utiliser pour des ressorts, des spires et des nervures hélicoïdales.", "help.solid.combine": "Joint des corps, les retire d'un corps cible ou ne garde que le volume qu'ils ont en commun, et peut conserver les corps outils. À utiliser pour fusionner des pièces, tailler une forme avec une autre ou repérer où deux corps se recouvrent.", + "help.solid.cone": "Crée un cône à partir d'un cercle de base et d'une hauteur, ou un tronc de cône lorsque le sommet a aussi un rayon. À utiliser pour des conicités, des entonnoirs, des fraisages et des pointes.", + "help.solid.cylinder": "Crée un cylindre à partir d'un rayon ou d'un diamètre et d'une hauteur, posé sur un point selon un axe. À utiliser pour des arbres, des goupilles, des bossages et des bruts ronds.", "help.solid.draft": "Incline les faces sélectionnées de l'angle indiqué, mesuré depuis un plan neutre. À utiliser sur les pièces moulées ou coulées pour qu'elles sortent du moule.", + "help.solid.emboss": "Fait saillir des profils d'esquisse, comme du texte ou un logo, hors d'une face, ou les enfonce dedans. À utiliser pour des lettrages, des étiquettes et des marquages sur des faces planes ou rondes.", "help.solid.extrude": "Pousse le profil d'une esquisse en 3D sur une distance, en ajoutant ou en retirant de la matière. À utiliser pour transformer un contour plat en bloc, en bossage ou en poche.", "help.solid.fillet": "Arrondit les arêtes sélectionnées par un raccord circulaire lisse du rayon indiqué. À utiliser pour adoucir des arêtes vives par souci de solidité, de confort ou d'aspect.", "help.solid.hole": "Perce un trou rond du diamètre indiqué en un point choisi d'une face. À utiliser pour les passages de vis, les goupilles et autres ouvertures rondes simples.", @@ -1428,20 +1644,60 @@ "help.solid.move": "Déplace ou fait tourner les corps sélectionnés, ou une copie, selon la distance et l'angle indiqués. À utiliser pour repositionner un corps ou en créer un second ailleurs.", "help.solid.offset_face": "Pousse les faces sélectionnées vers l'intérieur ou l'extérieur de la distance indiquée, et les faces voisines suivent. À utiliser pour épaissir une paroi, creuser une poche ou rehausser un bossage sans modifier l'esquisse.", "help.solid.pattern.circular": "Répète les objets sélectionnés autour d'un axe, selon le nombre et l'angle total indiqués. À utiliser pour les cercles de perçages, rayons, ailettes et dents.", + "help.solid.pattern.path": "Copie des fonctions ou des corps le long d'une trajectoire d'esquisse, selon un nombre et un espacement. À utiliser pour des trous le long d'une arête courbe ou des pièces le long d'un rail.", "help.solid.pattern.rectangular": "Répète les objets sélectionnés dans une ou deux directions, selon le nombre et l'espacement indiqués. À utiliser pour les rangées et grilles de trous, nervures, goupilles ou pieds.", + "help.solid.pipe": "Crée un tube rond le long d'une trajectoire de courbes d'esquisse, plein ou creux avec une épaisseur de paroi. À utiliser pour des tubes, des fils, des câbles et des rails.", "help.solid.press_pull": "Ajoute ou retire de la matière quand on fait glisser une face, ou arrondit des arêtes quand on les fait glisser. À utiliser pour des modifications rapides directement sur le modèle.", "help.solid.remove": "Retire les corps sélectionnés de la conception à partir de ce point de la chronologie ; les étapes précédentes les gardent. À utiliser pour écarter un corps d'aide, comme un outil de découpe devenu inutile.", "help.solid.replace_face": "Amène les faces planes sélectionnées sur un plan ou une face parallèle, et le corps s'allonge ou se raccourcit pour la rejoindre. À utiliser pour mettre une face au ras d'une autre pièce ou d'un plan de construction.", "help.solid.revolve": "Fait tourner le profil d'une esquisse autour d'un axe pour obtenir un solide de révolution. À utiliser pour les arbres, boutons, bols et toute pièce ronde autour d'une ligne centrale.", + "help.solid.rib": "Ajoute une paroi mince le long d'une courbe d'esquisse ouverte, prolongée jusqu'aux faces suivantes du corps. À utiliser pour rigidifier des parois et des coins, par exemple dans des pièces moulées ou imprimées.", "help.solid.scale": "Agrandit ou réduit les corps sélectionnés d'un facteur, identique dans toutes les directions ou propre à chaque axe. À utiliser pour redimensionner une forme importée ou compenser un retrait.", "help.solid.shell": "Évide un solide en laissant des parois de l'épaisseur indiquée, et peut ouvrir les faces choisies. À utiliser pour les boîtes, couvercles et autres pièces à parois minces.", + "help.solid.sphere": "Crée une sphère à partir d'un rayon ou d'un diamètre autour d'un centre. À utiliser pour des billes, des boutons et des extrémités arrondies.", "help.solid.split_body": "Scinde un corps en corps séparés avec un plan, la face d'un autre corps ou une ligne d'esquisse. À utiliser pour obtenir des pièces imprimées, usinées ou colorées séparément.", "help.solid.split_face": "Scinde les faces sélectionnées le long d'un plan, de la face d'un autre corps ou de courbes d'esquisse, sans scinder le corps. À utiliser pour donner à une partie de face sa propre dépouille, couleur ou décalage.", "help.solid.sweep": "Entraîne le profil d'une esquisse le long d'une trajectoire pour obtenir un solide. À utiliser pour les tuyaux, rails, poignées et toute pièce de section constante qui suit une courbe.", + "help.solid.thread": "Ajoute un filetage métrique ISO sur une face cylindrique, soit sous forme de filetage cosmétique, soit taillé dans le corps. À utiliser pour des boulons, des trous taraudés et des pièces vissées imprimées.", + "help.solid.torus": "Crée un anneau à partir d'un grand rayon autour de son axe et d'un petit rayon pour sa section ronde. À utiliser pour des joints toriques, des poignées et des perles rondes.", + "help.solid.web": "Ajoute une paroi mince le long de chaque courbe d'esquisse ouverte, chacune prolongée jusqu'aux faces suivantes du corps. À utiliser pour une grille de parois de soutien à l'intérieur de boîtiers et de plateaux.", + "help.surface.blend": "Crée une surface qui relie les arêtes de deux corps, en tangence ou en continuité de courbure avec les deux. À utiliser pour joindre en douceur deux surfaces à travers un écart.", + "help.surface.boundary": "Crée une surface remplissant une boucle de deux à quatre courbes d'esquisse, ou un réseau de courbes. À utiliser pour des panneaux de forme libre définis par leurs bords.", + "help.surface.extend": "Prolonge une surface au-delà de ses bords, d'une distance ou jusqu'à un plan ou une face. À utiliser pour que des surfaces se chevauchent avant de les ajuster ou de les coudre.", + "help.surface.extrude": "Pousse des courbes ou profils d'esquisse sur une distance pour former une surface, parois seulement, sans fonds. À utiliser pour des parois minces et pour des outils de coupe ou de partage.", + "help.surface.fair": "Lisse l'intérieur de faces de forme libre tandis que leurs bords restent en place. À utiliser pour atténuer les bosses et les reflets ondulés d'une surface.", + "help.surface.loft": "Crée une surface qui passe par deux sections ou plus dans l'ordre, sans fonds. À utiliser pour des coques et des transitions entre des formes différentes.", + "help.surface.match": "Reconstruit le bord d'une surface pour qu'il rejoigne le bord d'un autre corps, avec une tangence ou une courbure concordante. À utiliser pour fermer en douceur un raccord entre deux surfaces.", + "help.surface.offset": "Crée une copie de faces à une distance le long de leurs normales, sous forme de nouveau corps surfacique. À utiliser pour des peaux, des jeux ou l'autre côté d'une paroi.", + "help.surface.patch": "Remplit une région d'esquisse ou une boucle fermée d'arêtes par une face, plane ou courbe et douce. À utiliser pour boucher des trous dans un corps surfacique.", + "help.surface.reverse_normal": "Retourne des faces d'un corps surfacique, de sorte que leur côté extérieur soit orienté dans l'autre sens. À utiliser avant d'épaissir ou de coudre lorsque les faces pointent vers l'intérieur.", + "help.surface.revolve": "Fait tourner des courbes ou profils d'esquisse autour d'un axe pour former une surface, sans fonds. À utiliser pour des coques rondes comme des abat-jour, des bols et des buses.", + "help.surface.ruled": "Crée une bande de droites à partir d'une chaîne d'arêtes, selon la normale de la face, la tangente ou un angle. À utiliser pour des parois de dépouille, des dégagements et des surfaces de joint.", + "help.surface.stitch": "Coud des corps surfaciques le long de leurs arêtes communes, en un solide lorsqu'ils se referment. À utiliser pour transformer un ensemble de surfaces en un seul corps.", + "help.surface.sweep": "Déplace des courbes ou profils d'esquisse le long d'une trajectoire pour former une surface, sans fonds. À utiliser pour des gouttières, des moulures et d'autres formes minces qui suivent une trajectoire.", + "help.surface.thicken": "Donne une épaisseur à un corps surfacique et le transforme en solide. À utiliser pour fabriquer des pièces imprimables ou usinables à partir de surfaces.", + "help.surface.trim": "Coupe une surface avec un plan, une autre surface ou des courbes d'esquisse, et garde le côté que vous choisissez. À utiliser pour ajuster des surfaces à leur forme avant de les coudre.", + "help.surface.unstitch": "Sépare des faces d'un corps en corps surfaciques distincts ; un solide se décompose en surfaces. À utiliser pour retravailler des faces isolées d'un corps.", + "help.surface.untrim": "Supprime des trous d'une face, ou rétablit son contour extérieur aux limites naturelles de la surface. À utiliser pour retrouver la surface complète après un ajustement.", "help.third_party": "Licences tierces", "help.timeline.compute_all": "Recalcule toutes les fonctions de la chronologie depuis le début. À utiliser pour rafraîchir le modèle et voir quelles fonctions échouent.", "help.timeline.delete": "Supprime les fonctions sélectionnées de la chronologie ; supprimer une esquisse supprime aussi les fonctions construites à partir d'elle. À utiliser pour retirer une étape devenue inutile.", + "help.timeline.dependents": "Liste les fonctions qui seraient supprimées avec une fonction, et celles qui échoueraient sans elle. À utiliser avant de supprimer ou de modifier une fonction.", "help.timeline.play": "Rejoue les fonctions restantes une par une, avec une pause d'une demi-seconde après chaque reconstruction. Relancez cette commande ou appuyez sur Échap pour arrêter ; Aller à la fin saute immédiatement.", + "help.view.apply": "Oriente la vue selon une direction standard comme face, dessus ou iso, ou selon une vue nommée enregistrée. À utiliser pour regarder le modèle sous un angle fixe.", + "help.view.delete": "Supprime une vue nommée enregistrée. Le modèle n'est pas modifié.", + "help.view.fit": "Zoome et déplace la vue pour que tout le modèle, ou la sélection, remplisse la vue. À utiliser lorsque le modèle est sorti du champ.", + "help.view.grid": "Affiche ou masque la grille dans la vue 3D. À utiliser comme repère de taille et d'orientation pendant la modélisation.", + "help.view.ground_shadow": "Affiche ou masque l'ombre du modèle sur le sol. À utiliser pour juger plus facilement la hauteur et la position.", + "help.view.home": "Remet la vue dans la direction d'accueil et ajuste le modèle. À utiliser pour revenir rapidement à une vue connue.", + "help.view.list": "Liste les vues nommées enregistrées dans la conception. À utiliser pour retrouver une vue enregistrée plus tôt.", + "help.view.navigate": "Fait tourner, déplace ou zoome la vue par étapes, comme un quart de tour à gauche. À utiliser pour déplacer la caméra avec précision plutôt qu'en faisant glisser.", + "help.view.navigation_bar": "Affiche ou masque la barre de navigation en bas de la vue 3D. Masquez-la pour gagner de la place, affichez-la pour accéder aux outils de vue à la souris.", + "help.view.rename": "Donne un nouveau nom à une vue nommée enregistrée. La vue elle-même reste inchangée.", + "help.view.save": "Enregistre la position actuelle de la caméra comme vue nommée dans la conception. À utiliser pour revenir plus tard au même angle, par exemple pour des captures d'écran.", + "help.view.spin": "Fait orbiter la vue autour du modèle en continu jusqu'au prochain clic ou à la prochaine touche. À utiliser pour présenter un modèle sous tous les angles.", + "help.view.tool": "Verrouille un outil de caméra, orbite, déplacement ou zoom, de sorte que faire glisser avec le bouton gauche de la souris déplace la vue jusqu'à ce que vous le désactiviez. À utiliser sur un trackpad ou sans bouton central de souris.", + "help.view.visual_style": "Change le mode d'affichage du modèle : ombré avec ou sans arêtes, filaire, ou avec arêtes cachées. Utilisez le filaire pour voir à l'intérieur, l'ombré pour un rendu net.", "history.sketch_under_constrained": "Sous-contraint ({count} degrés de liberté)", "history.sketch_under_constrained_one": "Sous-contraint ({count} degré de liberté)", "hole_presets.confirm_delete": "Confirmer la suppression", @@ -1618,6 +1874,10 @@ "material.selection.mixed": "Matériaux différents", "measure.at": "À", "measure.item": "Élément {number}", + "measure.quick_copied": "Mesure copiée", + "measure.quick_copy": "Cliquer pour copier la mesure", + "measure.quick_edges": "{count} arêtes", + "measure.quick_faces": "{count} faces", "messages.ago_h": "il y a {n} h", "messages.ago_min": "il y a {n} min", "messages.ago_s": "il y a {n} s", @@ -1923,6 +2183,8 @@ "prefs.interface_language": "Langue de l'interface", "prefs.interface_note": "L'apparence de SolidCraft et la place de ses éléments.", "prefs.interval_minutes": "{n} min", + "prefs.keep_work_on_tab_switch": "Garder dialogues et outils ouverts en changeant de conception", + "prefs.keep_work_on_tab_switch_note": "Chaque onglet de conception garde son dialogue ou son outil ouvert, avec ce que vous y avez sélectionné et saisi, ainsi que son onglet de barre d'outils. Vous les retrouvez en revenant à la conception. Désactivé : changer de conception les annule et affiche l'onglet Modèle.", "prefs.labels": "Libellés", "prefs.layout": "Disposition", "prefs.layout_note": "Compacte : ancre les panneaux aux bords de la fenêtre. La transparence du navigateur est disponible en mode Flottante. Changez le côté de l'assistant dans la page Assistant.", @@ -1996,6 +2258,8 @@ "prefs.quality_fastest": "le plus rapide", "prefs.quality_slowest": "le plus lent", "prefs.quality_smoother": "plus fluide", + "prefs.quick_measure": "Mesure rapide dans le coin de la vue", + "prefs.quick_measure_note": "La taille de la sélection, dans le coin inférieur droit de la vue : la longueur d'une arête, ou Ø et R pour une arête ronde, l'aire d'une face, la position d'un point, la distance et l'angle entre deux éléments, le total de plusieurs arêtes ou faces. Un clic la copie.", "prefs.render_previous_geometry_budget": "Utiliser l'ancien budget de géométrie de l'aperçu Rendu", "prefs.render_previous_geometry_budget_help": "Rétablir l'ancienne limite fixe de géométrie (512 Mio sur un ordinateur 64 bits, 128 Mio ailleurs) au lieu du budget basé sur l'appareil.", "prefs.reports_saved": "Enregistrés : {n}", @@ -2041,6 +2305,8 @@ "prefs.sketch_tab_always_note": "L'onglet Esquisse reste dans le ruban, juste après Modèle, tant qu'aucune esquisse n'est ouverte. Ses outils de dessin démarrent alors une esquisse sur le plan ou la face sélectionnés, ou sur le plan que vous choisissez ensuite, avec l'outil prêt à l'emploi.", "prefs.sketch_tracking_guides": "Guides d'alignement sur le centre et le point milieu", "prefs.sketch_tracking_guides_note": "Survolez le centre d'un cercle ou le point milieu d'un segment droit, puis déplacez le pointeur horizontalement ou verticalement pour afficher un guide pointillé temporaire.", + "prefs.sketch_tracking_points": "Guides d'alignement depuis les extrémités et les coins pendant le dessin", + "prefs.sketch_tracking_points_note": "Quand un outil de dessin attend un point, survoler l'extrémité d'une ligne ou d'un arc, le coin d'un rectangle ou un point libre affiche le même guide pointillé temporaire qu'un centre ou un point milieu. Désactivé : seulement les centres et les points milieux.", "prefs.sketching": "Esquisse", "prefs.snap_drawing_note": "Pendant le dessin ou le déplacement d'un point d'esquisse.", "prefs.source": "Source", @@ -2057,6 +2323,7 @@ "prefs.status_error": "Erreur", "prefs.status_selected": "Sélectionné", "prefs.status_warning": "Avertissement", + "prefs.structure.scale_percent": "{percent} %", "prefs.supersampling_brief": "Dessine la vue en plus grand puis la réduit par filtrage : très net, mais plus lourd.", "prefs.tab_accent_note": "La couleur d'accent suit l'onglet actif", "prefs.theme_file": "fichier de thème", @@ -2068,6 +2335,16 @@ "prefs.theme_follow_off": "Désactivé", "prefs.theme_follow_stop": "Arrêter le suivi", "prefs.theme_import_hint": "Cliquez pour choisir un fichier de thème, déposez-en un ici, ou appuyez sur Ctrl+V (Cmd+V sur Mac) avec un thème copié", + "prefs.theme_option_opacity": "Opacité", + "prefs.theme_option_opacity_help": "Règle l'opacité des panneaux flottants. Un seuil de lisibilité protège le texte placé sur les couleurs de la vue 3D.", + "prefs.theme_option_tab_colours": "Couleurs des onglets", + "prefs.theme_option_tab_colours_help": "Utilise les couleurs de panneau et de vue 3D de ce thème pour chaque onglet du ruban.", + "prefs.theme_option_tab_fade_ms": "Transition des onglets", + "prefs.theme_option_tab_fade_ms_help": "Fait passer progressivement les couleurs du panneau et de la vue 3D au changement d'onglet. Zéro bascule instantanément.", + "prefs.theme_options_badge": "Options", + "prefs.theme_options_more": "Plus · {count}", + "prefs.theme_options_reset": "Réinitialiser", + "prefs.theme_options_unavailable": "Indisponible sur cet appareil", "prefs.theme_paste": "Coller le thème", "prefs.theme_paste_none": "Le presse-papiers ne contient aucun thème SolidCraft", "prefs.timeline_half_hour": "−30 min", @@ -2089,6 +2366,8 @@ "prefs.under_cursor": "Sous le curseur", "prefs.units_precision": "Unités et précision", "prefs.updates": "Mises à jour", + "prefs.value_boxes_in_view": "Afficher les valeurs à côté de leurs poignées dans la vue", + "prefs.value_boxes_in_view_tip": "La valeur d'une commande (distance, rayon…) dans une case à côté de sa poignée, par-dessus le modèle. Désactivé : vous la saisissez dans la fenêtre de la commande, qui prend le clavier après une sélection, et rien ne masque le modèle.", "prefs.view": "Comportement de la vue", "prefs.view_confirm_corner": "OK et Annuler dans le coin de la vue", "prefs.view_confirm_corner_note": "Lorsqu'une boîte de dialogue ou une esquisse est ouverte, une coche et une croix en haut à droite de la vue font la même chose que OK et Annuler, ou terminent et annulent l'esquisse. Le repère de navigation se place en dessous.", @@ -2098,6 +2377,12 @@ "prefs.viewport_background": "Fond", "prefs.viewport_background_colour": "Couleur", "prefs.viewport_background_theme": "Dégradé du thème", + "prefs.viewport_section_status": "Afficher l'état de la coupe dans la vue 3D", + "prefs.viewport_section_status_help": "Affiche le réglage de coupe dans la vue 3D. Échap et le bouton actif du ruban le désactivent. Désactivez cette option pour conserver les contrôles de coupe précédents.", + "prefs.viewport_status_details": "Détails de la vue 3D modifiables", + "prefs.viewport_status_details_help": "Modifie les réglages de l'analyse et de la coupe actives dans des fiches de détails standard. Désactivez cette option pour retrouver les légendes d'analyse en lecture seule et la boîte de dialogue Coupe.", + "prefs.viewport_status_summary": "Regrouper les éléments d'état de la vue 3D", + "prefs.viewport_status_summary_help": "Regroupe trois éléments d'état de la vue 3D ou plus dans un seul badge de synthèse. Désactivez cette option pour garder tous les éléments sur la ligne complète.", "prefs.wheel": "Molette", "prefs.wheel_trackpad": "Molette et pavé tactile", "prefs.workspace": "Espace de travail", @@ -2976,7 +3261,9 @@ "render.output_ready": "Prêt à lancer le rendu", "render.output_run_check": "Lancer la vérification", "render.output_size_limit": "{method} : jusqu'à {dimension} px par côté et {pixels} pixels. Plus grande taille possible : {width} × {height}.", + "render.output_size_method_profile": "Pour un rendu en {width} × {height}, choisissez {method} avec le profil de ressources CPU {profile}, ou une taille plus petite.", "render.output_size_ok": "Taille correcte · {width} × {height} px", + "render.output_size_profile": "Pour un rendu en {width} × {height}, choisissez le profil de ressources CPU {profile}, ou une taille plus petite.", "render.output_swap_orientation": "Permuter largeur et hauteur", "render.output_tools_inspector": "Modifier la taille et le format dans l'inspecteur Sortie.", "render.output_view_image": "Afficher l'image", @@ -3189,6 +3476,7 @@ "render.size_custom": "Personnalisé…", "render.size_full_hd": "Full HD", "render.size_hd": "HD", + "render.size_needs_profile": "{size} · nécessite {profile}", "render.size_presets": "Préréglages de taille", "render.size_presets_pending": "Arrive avec l'éditeur Sortie", "render.size_preview": "Aperçu", @@ -3476,6 +3764,24 @@ "sheet.flange_type_contour": "Contour", "sheet.flange_type_edge": "Arête", "sheet.no_body": "il n'y a pas de corps de tôlerie : commencez-en un avec Bord tombé > Base, ou transformez une plaque en tôlerie avec Convertir en tôlerie (choisissez sa grande face plane)", + "sheet.personal_rules.confirm_delete": "Confirmer la suppression", + "sheet.personal_rules.delete": "Supprimer", + "sheet.personal_rules.delete_prompt": "Supprimer cette règle personnelle ? Les règles déjà présentes dans les conceptions ne changent pas.", + "sheet.personal_rules.full": "La bibliothèque personnelle contient déjà 64 règles.", + "sheet.personal_rules.invalid_name": "Saisissez un nom court, sans saut de ligne ni caractère de contrôle.", + "sheet.personal_rules.invalid_values": "Complétez les valeurs de la règle : chacune doit pouvoir être calculée dans cette conception, avec une épaisseur positive et un facteur K compris entre 0 et 1.", + "sheet.personal_rules.missing": "La règle personnelle sélectionnée n'est plus disponible.", + "sheet.personal_rules.name": "Nom de la règle", + "sheet.personal_rules.name_exists": "Une règle personnelle porte déjà ce nom. Choisissez-en un autre.", + "sheet.personal_rules.name_hint": "Un nom unique", + "sheet.personal_rules.overwrite": "Enregistrer les modifications de la règle personnelle sélectionnée", + "sheet.personal_rules.rename": "Renommer", + "sheet.personal_rules.rule": "Règle personnelle", + "sheet.personal_rules.save": "Enregistrer comme nouvelle", + "sheet.personal_rules.scope": "Enregistrée dans votre profil, pour toutes les conceptions.", + "sheet.personal_rules.select": "Choisir une règle", + "sheet.personal_rules.title": "Règles personnelles", + "sheet.personal_rules.unsupported": "Cette règle contient des valeurs que cette version ne peut pas utiliser.", "shortcut.json_error": "les paramètres doivent être au format JSON : {error}", "shortcut.no_command": "la commande `{id}` n’existe pas ; le texte avant le JSON doit être un ID de commande", "shortcut.pin": "Épingler en haut de la boîte S", @@ -3723,6 +4029,7 @@ "ui.agent_chat_auth_terminal": "Exécuter ceci dans un terminal, puis appuyer de nouveau sur Connecter :", "ui.agent_chat_auth_title": "Authentification auprès de l'agent", "ui.agent_chat_cancel": "Annuler", + "ui.agent_chat_cannot_start": "Installé, mais ne démarre pas : {error}", "ui.agent_chat_changes": "Modifications : {n}, en une seule étape d'annulation", "ui.agent_chat_changes_n": "Modifications : {n}", "ui.agent_chat_changes_of": "{n}/{limit} modifications", @@ -3815,9 +4122,12 @@ "ui.agent_chat_queued_stopped": "En file d'attente (arrêté, non envoyé)", "ui.agent_chat_refresh": "Actualiser", "ui.agent_chat_refused": "Interdit à l'agent : {reason}", + "ui.agent_chat_reinstall": "{agent} est installé ({program}), mais n'a pas démarré quand SolidCraft l'a vérifié : {error}\nSon installation est peut-être incomplète, ou prévue pour un autre type d'ordinateur. Réinstallez-le avec `{install}`, vérifiez qu'il affiche sa version dans un terminal, puis cliquez sur Actualiser.", "ui.agent_chat_remove": "Retirer de la file d'attente", "ui.agent_chat_remove_file": "Retirer de ce message", "ui.agent_chat_remove_imported": "Retirer de ce message ; la pièce reste dans la conception (Annuler la retire)", + "ui.agent_chat_reply_cut_off": "La réponse a atteint sa longueur maximale avant la fin : elle est peut-être incomplète. Demandez à l'agent de continuer, ou une réponse plus courte.", + "ui.agent_chat_reply_declined": "Le modèle a refusé de répondre à ce message.", "ui.agent_chat_retry": "Réessayer", "ui.agent_chat_right": "Droite", "ui.agent_chat_save": "Enregistrer", @@ -3876,12 +4186,17 @@ "ui.ai_copy_suffix": "copie", "ui.ai_custom_url": "URL personnalisée", "ui.ai_default": "Par défaut", + "ui.ai_edit": "Modifier", + "ui.ai_edit_agent_title": "Modifier l'agent", + "ui.ai_edit_gone": "Cet agent a été retiré : il n'y a rien à enregistrer.", + "ui.ai_edit_tip": "Modifier le nom, la commande ou le point d'accès de cet agent. La clé enregistrée reste masquée.", "ui.ai_http_busy": "le point d'accès est occupé ou a dépassé sa limite ; réessayez plus tard", "ui.ai_http_key": "le point d'accès a refusé la clé : vérifiez-la dans Préférences › IA", "ui.ai_http_not_found": "point d'accès ou modèle introuvable : vérifiez l'URL de base et le nom du modèle", "ui.ai_http_too_large": "la conversation est trop volumineuse pour ce point d'accès ; commencez une nouvelle discussion", "ui.ai_key": "Clé d'API", "ui.ai_key_hint": "facultative ; conservée dans le trousseau de clés du système", + "ui.ai_key_keep_hint": "laisser vide pour garder la clé enregistrée", "ui.ai_key_needs_https": "Une clé n'est envoyée qu'en HTTPS, ou vers cet ordinateur.", "ui.ai_key_note": "Stockée dans le trousseau de clés du système, jamais dans les réglages de SolidCraft. Laisser vide pour un serveur local.", "ui.ai_keychain_error": "Le trousseau de clés du système a refusé : {error}", @@ -3913,6 +4228,7 @@ "ui.ai_request_timeout": "Délai d'expiration des requêtes", "ui.ai_request_timeout_hint": "Durée maximale d'une requête au modèle de ce point d'accès, de 30 s à une heure. Augmentez-la pour un modèle local lent.", "ui.ai_reset": "Réinitialiser", + "ui.ai_save": "Enregistrer", "ui.ai_settings": "Réglages IA…", "ui.ai_show_features": "Afficher les fonctionnalités IA", "ui.ai_show_features_note": "Le chat de l'agent : un panneau où un agent de programmation que vous utilisez déjà, ou un point d'accès de modèle de votre choix, travaille sur la conception ouverte. Désactivé : ni bouton IA, ni raccourci, ni entrée de menu, ni panneau, et rien n'est recherché ni lancé.", @@ -4086,6 +4402,40 @@ "ui.design_properties.project": "Projet", "ui.design_properties.revision": "Indice", "ui.design_saved": "Enregistré dans {path}", + "ui.details_access_caption": "Les faces indiquent si un outil peut les atteindre depuis la direction choisie.", + "ui.details_angle": "Angle", + "ui.details_continuity_caption": "Modifie la jonction sélectionnée. Les autres jonctions gardent leurs propres réglages.", + "ui.details_continuity_stale": "Géométrie antérieure : relancez l'analyse pour modifier ou cadrer cette jonction.", + "ui.details_copy_list": "Copier la liste", + "ui.details_curvature": "Tolérance de courbure", + "ui.details_curvature_caption": "Les faces sont colorées selon leur rayon de courbure local.", + "ui.details_direction": "Direction de l'outil", + "ui.details_draft_caption": "Les faces sont colorées selon leur angle avec la direction de démoulage.", + "ui.details_edit_seam": "Modifier la jonction", + "ui.details_fixture": "Spécimen de composant — données d'exemple uniquement", + "ui.details_fixture_caption": "Spécimen de composant avec des valeurs d'illustration ; ce n'est pas un résultat du modèle.", + "ui.details_fixture_problem": "Exemple de constat signalant un problème", + "ui.details_fixture_readout": "Exemple de valeur mesurée", + "ui.details_flip": "Inverser", + "ui.details_gap": "Écart", + "ui.details_hatch": "Hachurer les faces coupées", + "ui.details_jump": "Saut de courbure", + "ui.details_key": "Légende", + "ui.details_offset": "Décalage", + "ui.details_plane": "Plan", + "ui.details_pull": "Direction de démoulage", + "ui.details_radius": "Rayon", + "ui.details_readout": "Valeur affichée", + "ui.details_samples": "Échantillons", + "ui.details_seam": "{body} · Arête {edge}", + "ui.details_settings": "Réglages", + "ui.details_sharp_gap": "Vive ou ouverte", + "ui.details_show_all": "Tout afficher ({count})", + "ui.details_smooth": "Lisse", + "ui.details_stripes": "Bandes", + "ui.details_target": "Cadrer la géométrie", + "ui.details_worst_seams": "Pires jonctions", + "ui.details_zebra_caption": "Les bandes réfléchies montrent la régularité du raccord entre les faces.", "ui.diameter": "Diamètre", "ui.dimension": "Cote", "ui.direct_interaction": "Interaction directe", @@ -4146,6 +4496,7 @@ "ui.equation_y_of_x": "y(x)", "ui.equivalent_radius": "Rayon équivalent", "ui.expand": "Développer", + "ui.explode_needs_two_components": "La vue éclatée écarte les composants les uns des autres : la conception doit en contenir au moins deux (Nouveau composant, Insérer une conception ou Créer des composants à partir des corps)", "ui.exportDxf": "Exporter en DXF…", "ui.export_ellipsis_keymap": "Exporter…", "ui.export_keymap_tip": "Raccourcis, favoris de la boîte S et barre d'outils, dans un fichier", @@ -4408,6 +4759,7 @@ "ui.part_size_shared": "Reconstruit la pièce en remplaçant les entités ajoutées. Toutes les occurrences de ce composant sont mises à jour.", "ui.paste": "Coller", "ui.path": "Trajectoire", + "ui.pattern_object_type_tip": "Corps : formes pleines, choisies dans la vue ou le Navigateur ; chacun est copié en entier.\nFonctions : étapes de l'Historique, comme une extrusion ou un perçage, choisies dans l'Historique ou sur une face qu'elles ont créée ; l'étape est répétée.\nUne esquisse n'est ni l'un ni l'autre : répétez ses courbes avec la Répétition ou la Symétrie de l'esquisse, ou extrudez-la d'abord.", "ui.pattern_on_path": "Répétition sur trajectoire", "ui.perforate_margin_default": "depuis le diamètre", "ui.perforate_pitch": "Pas", @@ -4473,6 +4825,7 @@ "ui.properties.inertia_y": "Iyx, Iyy, Iyz", "ui.properties.inertia_z": "Izx, Izy, Izz", "ui.properties.measuring": "Mesure en cours…", + "ui.properties.measuring_bodies": "Mesure en cours… {done} corps sur {total}", "ui.properties.measuring_failed": "La mesure s'est arrêtée avant la fin.", "ui.properties.principal_axis": "Axe principal {number}", "ui.properties.principal_moments": "Moments principaux", @@ -4540,6 +4893,7 @@ "ui.selectOther": "Sélectionner un autre…", "ui.selectTangentChain": "Sélectionner la chaîne tangente", "ui.select_all": "Tout sélectionner", + "ui.select_components_by_face": "Sélectionner des composants (une face sélectionne son composant)", "ui.selection": "Sélection", "ui.selection_modifiers": "Modificateurs de sélection", "ui.selection_shift_add_only": "Maj+clic ajoute sans retirer", @@ -4581,6 +4935,11 @@ "ui.sketch_intersect_selection_filter": "Filtre de sélection", "ui.sketch_modifiers": "Modificateurs d’esquisse", "ui.sketch_project_geometry": "Géométrie", + "ui.sketch_report.all_constrained": "Tout est entièrement contraint.", + "ui.sketch_report.dof": "Degrés de liberté", + "ui.sketch_report.loose": "Pas entièrement contrainte", + "ui.sketch_report.status": "État", + "ui.sketch_report.title": "Rapport d'esquisse", "ui.sketch_scale": "Échelle d'esquisse", "ui.sketches": "Esquisses", "ui.slice": "Trancher", @@ -4690,7 +5049,14 @@ "ui.viewport_status_key": "Afficher la légende de {name}", "ui.viewport_status_minimum_radius": "Rayon minimum", "ui.viewport_status_pairs": "{count} paires", + "ui.viewport_status_section_hatch": "{setting} · hachures", + "ui.viewport_status_section_plane": "Plan actuel", + "ui.viewport_status_section_tilted": "inclinée · {offset}", "ui.viewport_status_stripes": "{count} bandes", + "ui.viewport_status_summary_clear": "Tout désactiver", + "ui.viewport_status_summary_heading": "Dans cette vue", + "ui.viewport_status_summary_label": "{name} et {count} autres éléments", + "ui.viewport_status_summary_more": "+{count}", "ui.viewport_theme_follow": "Suivre le thème", "ui.visual_shaded_hidden_edges": "Ombré avec arêtes cachées", "ui.visual_shaded_with_edges": "Ombré avec arêtes", diff --git a/assets/locales/gl.json b/assets/locales/gl.json index 80b3bb4f49..f3b3e3abb9 100644 --- a/assets/locales/gl.json +++ b/assets/locales/gl.json @@ -60,6 +60,7 @@ "BLEND SURFACE": "SUPERFICIE DE UNIÓN", "BOUNDARY SURFACE": "SUPERFICIE DE CONTORNO", "BROWSER": "NAVEGADOR", + "Back to the original order": "Volver á orde orixinal", "Ball": "Esférica", "Bar": "Barra", "Base Center": "Centro da base", @@ -595,6 +596,8 @@ "Snap to face and edge centres": "Axustar aos centros de caras e arestas", "SolidCraft cannot submit reports directly. Submit on GitHub; no token is needed in the app.": "SolidCraft non pode enviar informes directamente. Envía o informe en GitHub; non se necesita ningún token na aplicación.", "SolidCraft closed before these designs were saved. Their last autosave can be opened.": "SolidCraft pechouse antes de gardar estes deseños. Pódese abrir a súa última gravación automática.", + "Sort A to Z": "Ordenar de A a Z", + "Sort Z to A": "Ordenar de Z a A", "Spacing 2 must be a non-zero number": "O espazado 2 debe ser un número distinto de cero", "Spacing must be a non-zero number": "O espazado debe ser un número distinto de cero", "Specialise": "Especializar", @@ -819,6 +822,7 @@ "cmd.appearance_pick_up": "Tomar aparencia", "cmd.appearance_replace": "Substituír aparencia", "cmd.assembly_bom": "Lista de materiais", + "cmd.assembly_mass_properties": "Propiedades de masa", "cmd.body.print_face": "Cara de impresión", "cmd.body_rename": "Renomear o corpo", "cmd.browser_collapse_all": "Contraer todo", @@ -1244,6 +1248,7 @@ "cmd.view.render_browser": "Navegador de render", "cmd.view.render_dock": "Mover o panel de render", "cmd.view.render_focus": "Vista de foco do render", + "cmd.view.status_details": "Detalles de estado da ventá gráfica", "cmd.view.status_items": "Elementos de estado da ventá gráfica", "cmd.view_ambient_occlusion": "Oclusión ambiental", "cmd.view_angle_readout": "Ángulos da vista", @@ -1387,21 +1392,168 @@ "from the rule": "da regra", "help.apache": "Licenza Apache 2.0", "help.appearance.assign": "Dá aos corpos, caras ou compoñentes seleccionados unha cor ou un aspecto de material, sen cambiar o seu peso. Úsao para distinguir pezas ou para previsualizar un acabado.", + "help.assembly.bom": "Lista as pezas do deseño ou dun compoñente coas súas cantidades, por número de peza, e pode gardar a lista como CSV ou JSON. Úsaa como lista de pezas para encargar e montar.", "help.attributions": "Atribucións (ATTRIBUTION.md)", "help.build": "Versión {version} · commit {commit} · compilada o {date}", + "help.canvas.decal": "Coloca unha imaxe nunha cara plana do modelo, como unha pegatina. Úsaa para mostrar unha etiqueta, un logotipo ou un debuxo impreso nunha peza.", + "help.canvas.edit": "Cambia un lenzo: a súa posición, tamaño, ángulo, opacidade, simetría, visibilidade ou nome. Úsao para aliñar unha imaxe de referencia co bosquexo que calcas sobre ela.", + "help.canvas.insert": "Coloca unha imaxe PNG ou JPEG nun plano ou nunha cara como referencia transparente. Úsao para calcar unha foto ou un debuxo nun bosquexo, coa imaxe a un tamaño coñecido.", + "help.component.break_link": "Separa un compoñente ligado do seu ficheiro de deseño e conserva os seus corpos tal como están. Úsao cando a peza xa non deba seguir os cambios do ficheiro orixinal.", + "help.component.capture_position": "Conserva as posicións das pezas que moveches a man, para que esas sexan as súas posicións. Úsao despois de arrastrar ata o seu sitio pezas que ningunha unión sostén.", + "help.component.create": "Engade un compoñente novo e baleiro ao deseño, dentro do compoñente activo salvo que escollas outro compoñente superior. Úsao para dar a unha peza nova da ensamblaxe o seu propio lugar para corpos e bosquexos.", "help.component.finish_edit": "Garda a peza que estás editando in situ, volve á súa ensamblaxe e actualiza a ensamblaxe a partir dela. Úsao cando remates os cambios na peza ligada.", + "help.component.from_bodies": "Converte cada corpo seleccionado nun compoñente propio, co nome do corpo. Úsao cando un deseño feito con corpos soltos deba pasar a ser unha ensamblaxe cuxas pezas se poidan unir e mover.", + "help.component.insert_design": "Insire outro deseño gardado como un compoñente ligado ao seu ficheiro, cos seus corpos sólidos colocados tal como están alí. Úsao para reutilizar unha peza nunha ensamblaxe e recibir os cambios posteriores con Actualizar as ligazóns.", + "help.component.properties": "Mostra o volume, a masa, o centro de masas e os momentos de inercia dun compoñente, segundo os materiais dos seus corpos. Úsao para comprobar o peso e o equilibrio dunha ensamblaxe.", + "help.component.revert_position": "Devolve ao seu sitio as pezas que moveches a man e descarta os movementos que non se capturaron. Úsao para desfacer movementos de proba nunha ensamblaxe.", + "help.component.update_links": "Le de novo os ficheiros de deseño ligados e substitúe os corpos que cambiaron. Úsao despois de editar unha peza que usa esta ensamblaxe.", + "help.config.configure": "Engade parámetros, operacións, materiais ou aparencias como columnas da táboa de configuracións. Úsao para crear variantes dun deseño, como tamaños dunha peza.", + "help.config.preview": "Comproba que unha fila da táboa de configuracións se reconstrúe, sen cambiar o deseño nin o seu historial. Úsao antes de cambiar a unha variante da que non estás seguro.", + "help.config.rules": "Mostra as regras da táboa de configuracións, as súas filas e cal é a fila activa. Úsao para ver como están definidas as variantes dun deseño.", + "help.config.table": "Mostra a táboa de configuracións: unha fila por variante, unha columna por parámetro ou operación, e se o deseño aínda coincide coa fila activa. Úsao para revisar as variantes dun deseño.", "help.connect_note": "Reinicia cada asistente para cargar o servidor de SolidCraft. De cada ficheiro fíxose unha copia de seguranza como .bak antes de cambialo.", "help.connect_title": "Conectar asistentes de IA", + "help.construct.axis.edge": "Crea un eixe de construción ao longo dunha aresta recta ou dunha liña do bosquexo. Úsao como eixe para unha revolución, un patrón circular ou unha unión.", + "help.construct.axis.normal_to_face": "Crea un eixe de construción por un punto, perpendicular a unha cara. Úsao como un eixe que sae directamente dunha superficie.", + "help.construct.axis.through_cylinder": "Crea un eixe de construción polo centro dunha cara de cilindro, cono ou toro, ou dunha aresta circular. Úsao para xirar operacións ou repetilas arredor dunha peza redonda.", + "help.construct.axis.two_planes": "Crea un eixe de construción onde se atopan dous planos ou caras planas. Úsao cando o eixe que precisas é a liña entre dúas caras.", + "help.construct.axis.two_points": "Crea un eixe de construción por dous puntos, vértices ou puntos do bosquexo. Úsao para un eixe que vai entre dúas posicións coñecidas.", + "help.construct.plane.along_path": "Crea un plano de construción perpendicular a unha aresta ou curva, nunha posición ao longo dela. Úsao para debuxar o perfil dun varrido ou unha sección dunha traxectoria.", + "help.construct.plane.at_angle": "Crea un plano de construción xirado un ángulo a partir dun plano ou dunha cara, arredor dun eixe ou dunha aresta. Úsao para bosquexos inclinados, como unha escuadra en ángulo.", + "help.construct.plane.midplane": "Crea un plano de construción a medio camiño entre dous planos ou caras planas, ou que os bisecta onde se atopan formando un ángulo. Úsao como plano de simetría.", + "help.construct.plane.offset": "Crea un plano de construción paralelo a un plano ou a unha cara plana, a unha distancia ou por un punto. Úsao para debuxar a unha altura fixa sobre unha cara.", + "help.construct.plane.perpendicular": "Crea un plano de construción que contén unha liña ou aresta e é perpendicular a un plano ou a unha cara. Úsao para debuxar en vertical sobre un modelo ao longo dunha aresta.", + "help.construct.plane.tangent": "Crea un plano de construción que toca un cilindro, cono, esfera ou toro nun punto. Úsao para debuxar no lado dunha peza redonda.", + "help.construct.plane.three_points": "Crea un plano de construción por tres puntos. Úsao para un plano definido por posicións do modelo, como tres esquinas.", + "help.construct.plane.two_edges": "Crea un plano de construción por dúas arestas ou liñas rectas que están nun mesmo plano. Úsao para un plano que atravesa unha peza, como por dúas arestas opostas.", + "help.construct.point.along_path": "Crea un punto de construción nunha posición ao longo dunha aresta ou curva. Úsao como referencia a medio camiño dunha traxectoria.", + "help.construct.point.center": "Crea un punto de construción no centro dunha aresta circular, dun círculo ou arco do bosquexo, dunha esfera ou dun toro. Úsao como referencia para furados, eixes e unións.", + "help.construct.point.edge_plane": "Crea un punto de construción onde unha aresta ou liña atravesa un plano ou unha cara plana. Úsao para marcar onde unha traxectoria cruza un plano.", + "help.construct.point.three_planes": "Crea un punto de construción onde se atopan tres planos ou caras planas. Úsao como referencia de esquina, mesmo onde o modelo non ten vértice.", + "help.construct.point.two_edges": "Crea un punto de construción onde se atopan dúas arestas ou liñas, ou onde están máis preto. Úsao como referencia nunha esquina virtual.", + "help.construct.point.vertex": "Crea un punto de construción nun vértice ou punto do bosquexo. Úsao como referencia que se mantén cando se oculta o bosquexo ou o corpo ao seu redor.", + "help.contact.check": "Lista os pares de pezas dos conxuntos de contacto que se atravesan na súa posición actual. Úsao para atopar colisións antes de accionar as unións ou executar un estudo de movemento.", + "help.contact.create": "Agrupa dúas ou máis pezas nun conxunto de contacto, cuxos corpos non se poden atravesar mentres as unións se moven. Úsao para pezas que deben parar ao tocarse, como unha panca contra o seu tope.", + "help.contact.disable_all": "Desactiva o contacto, de modo que as unións accionadas e os estudos de movemento xa non paran onde as pezas se tocan. Úsao para volver mover as pezas con liberdade, ou para manter áxil unha ensamblaxe grande.", + "help.contact.edit": "Cambia un conxunto de contacto: as pezas que contén, o seu nome ou se está suprimido; tamén pode eliminar o conxunto. Úsao para axustar que pezas non se poden atravesar.", + "help.contact.enable_all": "Mete todas as pezas da ensamblaxe nun só conxunto de contacto, de modo que ningunhas dúas pezas se poden atravesar mentres as unións se moven. Úsao para unha comprobación rápida de todo un mecanismo.", + "help.contact.enable_sets": "Activa o contacto para os conxuntos de contacto que definiches, de modo que as unións accionadas e os estudos de movemento paran onde as súas pezas se atravesarían. Úsao para recuperar os teus conxuntos despois de desactivar o contacto.", + "help.contact.list": "Lista os conxuntos de contacto do deseño, as pezas de cada un e se o contacto está activado. Úsao para comprobar como está configurado o contacto antes de accionar a ensamblaxe.", + "help.document.inspect": "Mostra unha visión xeral do deseño: os seus compoñentes, corpos, bosquexos, operacións e parámetros. Úsao para orientarte nun deseño que non fixeches ti.", + "help.document.units": "Cambia as unidades nas que o deseño mostra as lonxitudes, como mm ou polgadas. A xeometría mantén o seu tamaño; só seguen o cambio a visualización e os números simples.", + "help.edit.checkpoint": "Recorda o deseño, o bosquexo activo e o historial de desfacer tal como están agora. Úsao antes de probar algo arriscado do que quizais queiras volver atrás.", + "help.edit.restore_checkpoint": "Volve a un punto de control, historial de desfacer incluído. Úsao para descartar de golpe todo o feito desde o punto de control.", + "help.explode.create": "Crea unha vista explosionada que separa as pezas do centro da ensamblaxe, ou segundo os movementos que fixes. Úsao para mostrar como se monta unha ensamblaxe, en instrucións ou presentacións.", "help.face.delete": "Elimina as caras seleccionadas e deixa que as caras veciñas pechen o oco, ou deixa o oco aberto como unha superficie. Úsao para quitar unha aresta arredondada, un furado ou un saínte que estorba.", + "help.file.insert_mesh": "Engade un ficheiro 3MF, STL, OBJ, PLY ou glTF ao deseño como corpos de malla nun compoñente novo. Úsao para colocar un modelo descargado ou dixitalizado xunto ao teu deseño como referencia.", + "help.file.insert_step": "Engade un ficheiro STEP, IGES, FreeCAD ou Rhino ao deseño como corpos sólidos nun compoñente novo; as pezas dunha ensamblaxe pasan a ser compoñentes aniñados. Úsao para traer modelos de provedores ou doutros programas CAD.", + "help.file.save_mesh": "Garda corpos como malla STL, 3MF ou OBJ, coa suavidade das caras curvas que escollas. Úsao para preparar pezas para a impresión 3D ou para programas que len mallas.", + "help.file.send_to_slicer": "Garda os corpos seleccionados como ficheiro 3MF e ábreo no laminador que escollas. Úsao para ir do modelo á impresión nun só paso.", "help.font_details": "A interface usa as fontes integradas de egui (Ubuntu Light e Hack, baixo as licenzas Ubuntu Font Licence e MIT/Bitstream Vera; Noto Emoji e a fonte de iconas emoji, baixo OFL e MIT). O texto dos bosquexos usa Liberation Sans (SIL OFL 1.1). O paquete opcional de fontes de The Crafting Apps, craft-fonts (BIZ UDPGothic, Shippori Mincho, BIZ UDMincho, Noto Sans CJK SC, Noto Sans Arabic; SIL OFL 1.1), non se inclúe nas compilacións de SolidCraft.", "help.fonts": "Fontes", + "help.frame.member": "Coloca un perfil, como un tubo ou un perfil de aluminio extruído, ao longo das liñas dun bosquexo de estrutura, un membro por liña. Úsao para construír estruturas, bastidores e conxuntos soldados.", + "help.inspect.accessibility": "Colorea as caras ás que unha ferramenta pode chegar desde unha dirección, e aquelas ás que non. Úsao para comprobar unha peza para mecanizado ou moldeo desde ese lado.", + "help.inspect.center_of_mass": "Mostra o centro de masas dos corpos, cada un por separado e todos xuntos, con densidade uniforme. Úsao para comprobar o equilibrio e onde vai envorcar unha peza.", + "help.inspect.colored_components": "Dá a cada compoñente a súa propia cor temporal, sen cambiar as aparencias. Úsao para distinguir dunha ollada as pezas dunha ensamblaxe.", + "help.inspect.compare": "Compara dous corpos e mostra onde un ten material que o outro non, con volume e desviación. Úsao para comprobar unha peza modificada ou importada contra a orixinal.", + "help.inspect.continuity": "Comproba con que suavidade se unen as caras ao longo das súas arestas: separacións, quebras ou saltos de curvatura, e colorea as xuntas. Úsao para atopar onde as superficies non se unen limpamente.", + "help.inspect.curvature_comb": "Debuxa pentes ao longo de curvas, arestas ou caras, cuxos dentes mostran canto se curvan. Úsao para detectar zonas planas, quebras e curvatura irregular.", + "help.inspect.curvature_map": "Colorea as caras segundo canto se curvan, en vermello onde o raio é pequeno. Úsao para atopar zonas axustadas e xulgar a suavidade dunha superficie.", + "help.inspect.draft": "Colorea as caras segundo o seu ángulo de desmoldaxe respecto dunha dirección de extracción e marca as que teñen demasiado pouco. Úsao para comprobar unha peza antes do moldeo ou da fundición.", + "help.inspect.environment_map": "Mostra o modelo cun acabado reflectante, como de espello. Úsao para xulgar o fluxo das superficies polo xeito en que os reflexos as percorren.", + "help.inspect.interference": "Atopa corpos que se solapan e mostra cada solapamento co seu volume. Úsao para comprobar se hai pezas que chocan nunha ensamblaxe.", + "help.inspect.isocurve": "Debuxa as curvas isoparamétricas das caras, a grella sobre a que se constrúe a superficie. Úsao para ver como flúe unha superficie e onde se amorea.", + "help.inspect.measure": "Mide lonxitudes, distancias, ángulos, áreas e raios entre o que escollas, ou o volume e a masa dos corpos. Úsao para comprobar tamaños e folgas.", + "help.inspect.minimum_radius": "Atopa o raio de curvatura máis pequeno de curvas, arestas ou caras, e onde está. Úsao para comprobar que unha ferramenta ou unha boquilla pode seguir a forma.", + "help.inspect.section": "Corta a vista ao longo dun plano e raia as caras cortadas, sen cambiar o modelo. Úsao para mirar dentro de pezas e ensamblaxes.", + "help.inspect.zebra": "Mostra raias brancas e negras reflectidas nas caras do modelo. Úsao para xulgar a suavidade: as raias que se rompen ou fan unha quebra nunha aresta indican unha transición deficiente.", + "help.joint.as_built": "Une dúas pezas onde xa están, sen mover ningunha, e define como poden seguir movéndose. Úsao para pezas que xa colocaches na súa posición final.", + "help.joint.cam": "Liga unha leva que xira cun seguidor, de modo que o seguidor se desliza ou oscila segundo lle pasa por baixo o contorno da leva. Úsao para levas, excéntricas e accionamentos de válvulas.", + "help.joint.create": "Une dúas pezas nas orixes de unión que escollas e move a segunda peza ao seu sitio; o tipo de unión define como poden seguir movéndose. Úsao para construír unha ensamblaxe cuxas pezas se moven como o mecanismo real.", + "help.joint.drive": "Define o ángulo ou a distancia dunha unión e move as pezas con ela, dentro dos seus límites; as unións ligadas e os bucles pechados seguen o movemento. Úsao para comprobar que un mecanismo se move como se pretende.", + "help.joint.gear": "Liga dúas unións xiratorias cunha relación de engrenaxe, de modo que accionar unha fai xirar a outra. Úsao para engrenaxes rectas e coroas dentadas, coa relación calculada a partir dos raios primitivos ou do número de dentes.", + "help.joint.limits": "Define canto pode xirar ou deslizar unha unión, cun mínimo e un máximo, ou borra eses límites. Úsao para manter unha charnela, unha corredoira ou unha panca dentro do seu rango real de percorrido.", + "help.joint.motion_link": "Acopla dúas unións cunha relación e un desfase, de modo que accionar unha move a outra. Úsao para unha relación fixa entre unións que Engrenaxe, Cremalleira e piñón ou Parafuso non cobren.", + "help.joint.origin": "Coloca unha orixe de unión con nome nunha peza: no centro dunha cara ou dun extremo redondo, no medio dunha aresta, nunha esquina ou nun punto. Úsao para preparar onde se fixan as unións; a orixe escollida nunha cara segue a cara cando a peza cambia de tamaño.", + "help.joint.rack_pinion": "Liga un piñón que xira cunha cremalleira que se desliza, de modo que a cremalleira se move tanto como a lonxitude de circunferencia primitiva que percorre o piñón. Úsao para accionamentos de cremalleira, dirección e eixes lineais.", + "help.joint.reach": "Move un punto dunha peza cara a un obxectivo cambiando as unións da súa cadea, dentro dos seus límites. Úsao para poñer un mecanismo en postura segundo onde debe acabar a súa punta, como a dun brazo robótico.", + "help.joint.rigid_group": "Bloquea xuntas as pezas seleccionadas tal como están, para que se movan como unha soa. Úsao para pezas que van xuntas e non teñen movemento entre elas.", + "help.joint.screw": "Liga o xiro e o deslizamento mediante un paso, de modo que cada volta avance esa distancia. Úsao para fusos de avance, parafusos e fusos roscados.", + "help.library.apply": "Aplica un material ou unha aparencia dun ficheiro de biblioteca local a corpos, caras ou compoñentes. Úsao para reutilizar materiais que gardaches doutro deseño.", + "help.library.export": "Garda os materiais e as aparencias do deseño nun ficheiro de biblioteca. Úsao para compartilos ou reutilizalos noutros deseños.", + "help.library.inspect": "Comproba un ficheiro de biblioteca e lista os seus materiais e aparencias, sen cambiar o deseño. Úsao para ver o que contén unha biblioteca antes de aplicala.", "help.licences": "Licenzas", "help.material.assign": "Define de que material están feitos os corpos seleccionados, como aceiro, aluminio ou ABS. Úsao para que a masa e o peso coincidan coa peza real.", + "help.material.properties.validate": "Comproba que unha ficha de datos de material teña números coherentes e non lle falten valores. Úsao antes de fiarte dos datos para a masa ou a rixidez.", + "help.mesh.convert": "Reconstrúe un corpo de malla, como un STL, como un sólido editable con caras planas e cilíndricas. Úsao para modificar pezas mecánicas importadas como se fosen un corpo modelado.", "help.mit": "Licenza MIT", + "help.motion.study": "Define valores das unións en pasos ao longo dunha liña de tempo e reprodúceos como animación. Úsao para ver como se move un mecanismo nunha secuencia e comprobar as folgas durante o percorrido.", + "help.motion.trace": "Traza o camiño que segue un punto dunha peza mentres se reproduce un estudo de movemento ou se acciona unha unión, e consérvao como curva de bosquexo 3D cando o confirmas. Úsao para ver ata onde chega un mecanismo, ou para construír sobre a súa traxectoria.", "help.notice": "NOTICE", + "help.occurrence.ground": "Fixa unha peza no seu sitio, ou volve liberala, para que as unións movan arredor dela as outras pezas. Úsao para o bastidor ou a base sobre a que se constrúe o resto da ensamblaxe.", "help.occurrence.move": "Move unha peza da ensamblaxe só segundo os graos de liberdade que permiten as súas unións. Selecciona unha cara da peza e despois arrastra unha frecha ou un arco dispoñible.", "help.parameters.change": "Abre a táboa de valores con nome e expresións que controlan as cotas do deseño. Úsao para cambiar un tamaño nun só lugar e que o sigan todas as operacións que o usan.", + "help.parameters.export": "Garda os parámetros do deseño como CSV ou JSON. Úsao para editalos nunha folla de cálculo ou levalos a outro deseño.", + "help.parameters.graph": "Mostra que parámetros e operacións usan cales outros, e os bucles ou erros que haxa entre eles. Úsao para ver a que afectará un cambio nun parámetro.", + "help.parameters.import": "Le parámetros dun CSV ou JSON no deseño, todos ou ningún. Úsao para traer valores dunha folla de cálculo ou doutro deseño.", + "help.parameters.move": "Move un parámetro de usuario antes ou despois doutro na lista. Úsao para manter xuntos os parámetros relacionados.", + "help.parts.fastener": "Engade un parafuso, perno, porca, arandela, pasador ou rodamento estándar no tamaño que escollas. Colocado nun furado, asenta nel cunha unión, así que non precisas modelar eses elementos de fixación ti mesmo.", + "help.parts.insert": "Insire unha peza estándar na familia, tamaño e lonxitude que escollas. Colocada no bordo ou na parede dun furado, asenta nel cunha unión ríxida.", + "help.parts.library": "Abre as pezas normalizadas: parafusos, pernos, porcas, arandelas, parafusos sen cabeza, pasadores de espiga e rodamentos en tamaños normalizados. Úsao para engadir unha peza normalizada en vez de modelala ti.", + "help.parts.resize": "Cambia o tamaño ou a lonxitude dunha peza normalizada inserida e reconstrúea; as súas unións mantérana asentada. Úsao cando un parafuso resulta longo de máis ou un furado precisa outro tamaño.", + "help.plastic.assign_rule": "Asigna unha regra de plástico a corpos, para que as súas operacións de plástico tomen o seu desmoldaxe e a súa folga como valores predeterminados. Úsao para configurar unha peza moldeada unha soa vez para todas as súas operacións.", + "help.plastic.boss": "Engade un saínte para parafuso nunha cara: un poste cun furado opcional, desmoldaxe, arredondamento da raíz e nervios de apoio. Úsao para puntos de parafuso en carcasas moldeadas ou impresas.", + "help.plastic.lip": "Engade un beizo ou unha ranura correspondente ao longo do bordo dun corpo baleirado. Úsao para que dúas metades dunha carcasa se aliñen entre si e pechen ben.", + "help.plastic.manage_rules": "Crea e edita regras de plástico: material, espesor de parede, raios, folga e desmoldaxe. Úsao para manter coherentes as operacións dunha peza moldeada.", + "help.plastic.rest": "Engade un apoio nunha cara: unha base redonda ou rectangular, maciza ou oca, con desmoldaxe. Úsao como soporte ou separador nunha carcasa moldeada.", + "help.plastic.snap_fit": "Engade un gancho de encaixe a presión nunha cara, coa súa lonxitude, espesor e retén. Úsao para suxeitar pezas sen parafusos.", "help.pointcloud.insert": "Insire unha dixitalización PLY, XYZ ou PTS como nube de puntos xunto ao modelo. Úsaa como referencia ao construír un modelo a partir de datos medidos.", + "help.sheet.convert": "Converte unha placa ou un corpo baleirado de espesor uniforme nun corpo de chapa, con dobras nas arestas que escollas. Úsao para poder despregar unha peza existente.", + "help.sheet.export_bend_table": "Garda a táboa de dobras dun corpo de chapa como CSV ou JSON: ángulos, raios e posicións das dobras no desenvolvemento. Úsao para a prensa plegadora ou o taller.", + "help.sheet.export_dxf": "Garda o desenvolvemento como DXF, con contornos, liñas de dobra e notas de dobra opcionais. Úsao para o corte por láser, plasma ou chorro de auga.", + "help.sheet.flange": "Engade unha pestana: unha placa base a partir dun bosquexo, unha parede ao longo dun contorno aberto do bosquexo ou unha parede dobrada nas arestas da chapa. Úsao para construír pezas de chapa.", + "help.sheet.flat_pattern": "Crea o desenvolvemento dun corpo de chapa, co seu tamaño, recortes e liñas de dobra. Úsao para comprobar a peza en bruto antes de cortala.", + "help.sheet.fold": "Dobra unha chapa ao longo dunha liña do bosquexo cun ángulo e un raio. Úsao para dobrar unha chapa plana despois de facerlle os cortes.", + "help.sheet.hem": "Dobra o bordo dunha chapa sobre si mesmo. Úsao para bordos seguros e ríxidos sen rebabas afiadas.", + "help.sheet.manage_rules": "Crea e edita regras de chapa: espesor, raio de dobra, factor K, desafogos e separacións. Úsao para axustalas ao material e ás ferramentas do teu taller.", + "help.sheet.path_flange": "Engade unha cadea de pestanas ao longo do bordo dunha chapa, seguindo unha cadea aberta de liñas do bosquexo. Úsao para perfís como unha forma de U ou de Z nun só paso.", + "help.sheet.perforate": "Engade un campo de furados por toda a chapa plana, nun patrón ao tresbolillo ou cadrado. Úsao para ventilacións, reixas e paneis máis lixeiros.", + "help.sheet.refold": "Volve dobrar un corpo de chapa despregado. Úsao despois de engadir operacións á chapa plana.", + "help.sheet.unfold": "Desprega todas as dobras dun corpo de chapa. Úsao para engadir furados ou cortes que teñen que cruzar unha dobra e despois volver dobrar.", + "help.sketch.arc.center_point": "Debuxa un arco circular arredor dun punto central, desde un punto inicial ata un punto final. Úsao cando se coñece o centro do arco, como un raio arredor dun furado ou dun pivote.", + "help.sketch.arc.tangent": "Debuxa un arco que comeza no extremo dunha liña ou dun arco e continúa tanxente a ela. Úsao para seguir unha cadea cunha curva suave en vez dunha esquina.", + "help.sketch.arc.three_point": "Debuxa un arco desde un punto inicial ata un punto final pasando por un terceiro punto. Úsao cando o arco ten que pasar por puntos dados en vez de arredor dun centro coñecido.", + "help.sketch.arc3d": "Debuxa un arco circular exacto no espazo por tres puntos, co Bosquexo 3D activado. Úsao para curvas en traxectorias e guías que saen do plano do bosquexo.", + "help.sketch.auto_constrain": "Engade as restricións que o bosquexo case ten xa, como horizontal, paralela ou coincidente, e despois acótao ata que quede totalmente definido. Úsao para fixar nun paso un bosquexo debuxado a man alzada.", + "help.sketch.auto_constrain_from_datum": "Funciona como Restrición automática, pero mide as posicións desde un punto de referencia que escollas en vez da orixe. Úsao cando a peza se dispón a partir dun só punto de referencia, como unha esquina ou un furado.", + "help.sketch.blend_curve": "Une os extremos de dúas curvas cunha spline suave tanxente a ambas, ou tamén continua en curvatura se o escolles. Úsao para salvar un oco entre curvas sen quebra.", + "help.sketch.break": "Divide unha curva en anacos onde a cruzan outras curvas, ou en puntos que coloques nela. Úsao cando unha parte dunha curva precisa a súa propia restrición ou debe eliminarse por separado.", + "help.sketch.cancel": "Sae do bosquexo e descarta todo o que cambiou desde que se creou ou se abriu para editalo. Desfacer recupera os cambios.", + "help.sketch.centerline": "Cambia as liñas entre liñas normais e liñas centrais, eixes que os perfís deixan fóra. Usa unha liña central como eixe para unha revolución, unha simetría ou unha restrición de simetría.", + "help.sketch.chamfer.distance_angle": "Biséla a esquina entre dúas liñas, definida por unha distancia ao longo da primeira liña e un ángulo. Úsao cando un debuxo dá o chafrán como lonxitude e ángulo, como 2 mm × 30°.", + "help.sketch.chamfer.equal_distance": "Biséla a esquina entre dúas liñas á mesma distancia ao longo de ambas. Úsao para o chafrán habitual de 45° nun contorno.", + "help.sketch.chamfer.two_distance": "Biséla a esquina entre dúas liñas, definida por unha distancia distinta ao longo de cada liña. Úsao para chafráns que non son de 45°, dados como dúas lonxitudes.", + "help.sketch.circle.center": "Debuxa un círculo a partir do seu centro e do seu diámetro. Úsao para furados, saíntes e outros elementos redondos cuxo centro se coñece.", + "help.sketch.circle.three_point": "Debuxa un círculo por tres puntos. Úsao cando o círculo ten que pasar por puntos dados, por exemplo dunha xeometría existente.", + "help.sketch.circle.three_tangent": "Debuxa un círculo que toca tres liñas, círculos ou arcos. Úsao para encaixar un círculo no espazo entre tres curvas, como o círculo inscrito dun triángulo.", + "help.sketch.circle.two_point": "Debuxa un círculo a partir dos dous extremos do seu diámetro. Úsao cando se coñecen os puntos do bordo do círculo en vez do seu centro.", + "help.sketch.circle.two_tangent": "Debuxa un círculo dun tamaño dado que toca dúas liñas, círculos ou arcos. Úsao para un arredondamento de raio fixo entre dúas curvas.", + "help.sketch.conic": "Debuxa unha curva cónica entre dous puntos extremos, definida por un punto ápice e un valor rho. Úsao para transicións suaves cuxa convexidade queres axustar, desde plana ata case unha esquina viva.", + "help.sketch.constrainer": "Aplica a restrición que unha ou dúas curvas escollidas case teñen xa, como horizontal, paralela, tanxente ou igual. Úsao para ordenar un bosquexo debuxado a man, unha relación cada vez.", + "help.sketch.constraint.coincident": "Fai que un punto estea sobre outro punto, unha curva ou un eixe do bosquexo. Úsao para pechar ocos nun perfil ou para pór un punto sobre unha liña.", + "help.sketch.constraint.collinear": "Fai que dúas liñas estean sobre unha mesma recta. Úsao para aliñar arestas interrompidas, como os dous lados dunha entalla.", + "help.sketch.constraint.concentric": "Dá a dous círculos ou arcos o mesmo centro. Úsao para aneis, avellanados cilíndricos e eixes con ombreiras.", + "help.sketch.constraint.curvature": "Fai que dúas curvas que se atopan nun punto extremo continúen coa mesma curvatura (G2). Úsao onde unha unión tanxente non é abondo de suave, por exemplo en superficies visibles.", + "help.sketch.constraint.equal": "Fai que as liñas teñan a mesma lonxitude, ou os círculos e arcos o mesmo raio. Úsao para que unha cota controle varios elementos iguais.", + "help.sketch.constraint.fix": "Fixa puntos ou curvas onde están, ou volve liberalos. Úsao para xeometría que non debe moverse cando arrastras ou resolves o bosquexo.", + "help.sketch.constraint.horizontal_vertical": "Fai horizontal ou vertical, o que estea máis preto, unha liña ou a liña entre dous puntos. Úsao para aliñar un contorno cos eixes do bosquexo.", + "help.sketch.constraint.midpoint": "Pon un punto no medio dunha liña, ou fai que coincidan os puntos medios de dúas liñas. Úsao para centrar xeometría nunha aresta.", + "help.sketch.constraint.parallel": "Fai paralelas dúas liñas. Úsao para lados que deben seguir paralelos cando cambian outras cotas.", + "help.sketch.constraint.perpendicular": "Fai que dúas liñas se atopen en ángulo recto. Úsao para esquinas en escuadra entre liñas que non están sobre os eixes do bosquexo.", + "help.sketch.constraint.polygon": "Converte unha cadea pechada de liñas nun polígono regular: lados iguais, con todas as esquinas nun mesmo círculo. Úsao para facer regular un polígono debuxado a man e mantelo así.", + "help.sketch.constraint.symmetry": "Fai que dous puntos ou curvas sexan simétricos respecto dunha liña ou dun punto. Úsao para manter simétrico un perfil cando cambias un lado.", + "help.sketch.constraint.tangent": "Fai que dúas curvas se toquen tanxencialmente, como unha liña e un arco. Úsao para unións suaves sen quebra.", + "help.sketch.construction": "Cambia as curvas entre xeometría normal e xeometría de construción, que os perfís deixan fóra. Usa curvas de construción como axudas para a disposición, referencias e cotas.", "help.sketch.construction_line": "Crea unha recta de construción infinita por dous puntos. Úsaa para organizar un bosquexo sen dividir os perfís.", "help.sketch.construction_line.angle": "Crea unha recta de construción infinita cun ángulo respecto do eixe X do bosquexo. Escolle o punto e escribe o ángulo, ou escolle a súa dirección.", "help.sketch.construction_line.bisector": "Escolle dúas rectas do bosquexo que se crucen e despois unha das dúas bisectrices. O resultado segue as súas rectas de orixe.", @@ -1412,11 +1564,71 @@ "help.sketch.construction_line.tangent_external": "Escolle un círculo ou un arco circular e despois un punto exterior e unha solución de tanxente. As vistas previas só mostran os puntos de tanxencia sobre o arco debuxado.", "help.sketch.construction_line.tangent_two": "Escolle dous círculos ou arcos circulares e despois unha tanxente común na vista previa. Os puntos de tanxencia deben estar sobre as curvas debuxadas.", "help.sketch.construction_line.vertical": "Crea unha recta de construción vertical infinita por un punto. A súa restrición vertical mantense nas edicións posteriores.", + "help.sketch.copy": "Copia as curvas e os puntos do bosquexo seleccionados, xunto coas restricións e cotas que hai entre eles. Pégaos neste bosquexo ou noutro.", + "help.sketch.create": "Inicia un bosquexo 2D novo nun plano ou nunha cara plana do modelo. Toda extrusión, revolución e varrido comeza cun bosquexo.", + "help.sketch.cut": "Copia a xeometría do bosquexo seleccionada e bórraa do bosquexo, nun só paso de desfacer. Pégaa noutro lugar deste bosquexo ou noutro.", + "help.sketch.dimension": "Engade unha cota de control: unha lonxitude, distancia, ángulo, raio ou diámetro, segundo o que escollas. Escribe un valor ou o nome dun parámetro e o bosquexo séguea.", + "help.sketch.edit": "Abre un bosquexo existente para editalo. As operacións construídas sobre el actualízanse cando remates o bosquexo.", + "help.sketch.ellipse": "Debuxa unha elipse a partir do seu centro, do extremo do seu eixe maior e dun punto do seu lado menor. Úsao para aberturas ovaladas, levas e outras formas redondas alongadas.", "help.sketch.equation_curve": "Debuxa unha curva a partir de fórmulas: x(t) e y(t), ou y = f(x), nun intervalo. As fórmulas poden usar parámetros e a curva segue os seus cambios: úsaa para unha onda senoidal, unha espiral ou a evolvente do dente dunha engrenaxe.", + "help.sketch.extend": "Alonga unha liña ou un arco ata a curva máis próxima que lle sae ao paso, ou fai crecer dúas curvas ata que se atopan. Úsao para pechar ocos nun perfil despois de recortar ou editar.", + "help.sketch.fillet": "Arredonda a esquina onde se atopan dúas liñas ou arcos cun arco dun raio dado. Varias esquinas arredondadas xuntas comparten un só raio.", + "help.sketch.finish": "Sae do bosquexo e volve ao modelo. Os seus perfís pechados quedan listos para Extruír, Revolución e outras operacións.", + "help.sketch.finish_auto_constrain": "Executa Restrición automática no bosquexo e despois remátao. Úsao para deixar un bosquexo totalmente definido nun só paso.", + "help.sketch.fit_curves_to_section": "Corta un corpo de malla ou un sólido co plano do bosquexo e axusta liñas e arcos á sección. Úsao para reconstruír como bosquexo editable unha peza dixitalizada ou importada.", + "help.sketch.include_3d": "Trae arestas do modelo, vértices e curvas doutros bosquexos a este bosquexo como referencias ligadas, aplanando sobre o seu plano as que están fóra del. Úsao para construír sobre xeometría que non está no plano do bosquexo.", + "help.sketch.infinite": "Cambia as liñas entre liñas normais e liñas de construción infinitas que cruzan toda a vista. Úsaas como referencias de disposición que nunca quedan curtas.", + "help.sketch.insert_dxf": "Le as curvas dun debuxo DXF nun bosquexo, escaladas, xiradas e colocadas onde indiques. Úsao para construír sobre un debuxo 2D doutro programa, como un contorno de corte por láser.", + "help.sketch.insert_svg": "Le os trazados dun debuxo SVG nun bosquexo, escalados, xirados e colocados onde indiques. Úsao para converter un logotipo ou un contorno vectorial en curvas que podes extruír.", + "help.sketch.inspect": "Abre un informe do bosquexo aberto: cantos graos de liberdade quedan e se está totalmente restrinxido. Úsao para saber o que aínda se move antes de rematar o bosquexo.", + "help.sketch.intersect": "Engade as curvas ou os puntos onde corpos, caras, arestas ou outros bosquexos cruzan o plano do bosquexo. Quedan ligados e seguen o modelo cando cambia.", + "help.sketch.intersection_curve": "Crea curvas 3D onde se atopan dous corpos ou caras, ligadas a eles. Úsaas como traxectorias ou guías que seguen o modelo.", + "help.sketch.isoparametric_curve": "Crea unha curva 3D na dirección U ou V dunha cara por un punto que escollas, ligada á cara. Úsaa como traxectoria ou liña de división que segue a superficie.", + "help.sketch.line": "Debuxa liñas conectadas punto a punto, como cadea aberta ou como contorno pechado. É a ferramenta básica para a maioría dos perfís.", + "help.sketch.line.midpoint": "Debuxa unha liña desde o seu punto medio ata un extremo, co outro extremo reflectido. Úsao para liñas centradas nun punto, como unha aresta simétrica.", + "help.sketch.line3d": "Debuxa liñas rectas conectadas entre puntos do espazo, co Bosquexo 3D activado. Úsao para traxectorias e estruturas que saen do plano do bosquexo.", + "help.sketch.mirror": "Reflicte curvas e puntos do bosquexo respecto dunha liña ou dun eixe e engade restricións de simetría. Úsao para debuxar a metade dun perfil simétrico e completalo.", + "help.sketch.move": "Move ou xira curvas e puntos do bosquexo, ou unha copia deles. Úsao para recolocar xeometría mantendo a súa forma e as súas restricións.", + "help.sketch.offset": "Crea unha copia dunha cadea de curvas a unha distancia dada, nun lado ou en ambos. Úsao para paredes, contornos e folgas; a distancia segue sendo unha cota editable.", + "help.sketch.paste": "Pega no bosquexo activo a xeometría de bosquexo que copiaches ou cortaches. Colócaa nun punto ou despráxaa unha distancia.", + "help.sketch.pattern.circular": "Copia xeometría do bosquexo arredor dun punto central, nun círculo completo ou nun ángulo dado. Úsao para círculos de parafusos, radios e dentes.", + "help.sketch.pattern.rectangular": "Copia xeometría do bosquexo en filas e columnas con separacións dadas. Úsao para grellas de furados, ranuras ou ventilacións.", + "help.sketch.point": "Coloca un punto de bosquexo. Úsao como referencia para cotas, restricións, centros de furados ou operacións posteriores.", + "help.sketch.point3d": "Coloca un punto no espazo, co Bosquexo 3D activado. Úsao como referencia para curvas e traxectorias 3D.", + "help.sketch.polygon.circumscribed": "Debuxa un polígono regular arredor dun círculo, de tamaño medido desde o seu centro ata o medio dun lado. Úsao cando se coñece a anchura entre planos, como nunha porca hexagonal.", + "help.sketch.polygon.edge": "Debuxa un polígono regular a partir dos dous extremos dun lado. Úsao cando se coñecen a lonxitude e a posición dun lado.", + "help.sketch.polygon.inscribed": "Debuxa un polígono regular dentro dun círculo, de tamaño medido desde o seu centro ata unha esquina. Úsao cando se coñece o tamaño entre esquinas.", + "help.sketch.project": "Trae arestas do modelo, caras, vértices e curvas doutros bosquexos a este bosquexo, proxectadas sobre o seu plano. Quedan ligadas e seguen o modelo cando cambia.", + "help.sketch.project_to_surface": "Proxecta curvas doutros bosquexos ao longo da normal deste bosquexo sobre unha cara, como curvas 3D ligadas. Úsao para poñer un logotipo, unha liña de división ou unha traxectoria sobre unha superficie curva.", + "help.sketch.rectangle.center": "Debuxa un rectángulo a partir do seu centro e dunha esquina. Úsao para contornos centrados nun punto, como a orixe.", + "help.sketch.rectangle.rounded": "Debuxa un rectángulo coas catro esquinas arredondadas a un mesmo raio. Úsao para placas, tapas e aberturas con esquinas arredondadas.", + "help.sketch.rectangle.three_point": "Debuxa un rectángulo a partir dun lado e dun punto do lado oposto. Úsao para rectángulos en ángulo cos eixes do bosquexo.", + "help.sketch.rectangle.two_point": "Debuxa un rectángulo a partir de dúas esquinas opostas. Úsao para os contornos de placas, bloques e rebaixes.", + "help.sketch.scale": "Escala curvas e puntos do bosquexo por un factor respecto dun punto base. Úsao para cambiar o tamaño dunha parte do bosquexo sen volver debuxala.", + "help.sketch.slot.arc_center": "Debuxa unha ranura curva dun ancho dado ao longo dun arco definido polo seu centro, inicio e fin. Úsao para ranuras de axuste arredor dun pivote.", + "help.sketch.slot.arc_three_point": "Debuxa unha ranura curva dun ancho dado ao longo dun arco por tres puntos. Úsao cando a traxectoria da ranura ten que pasar por puntos dados.", + "help.sketch.slot.center_point": "Debuxa unha ranura recta dun ancho dado desde o seu centro ata o centro dun arco extremo. Úsao para ranuras centradas nun punto.", + "help.sketch.slot.center_to_center": "Debuxa unha ranura recta dun ancho dado entre os centros dos seus dous arcos extremos. Úsao para ranuras acotadas de centro a centro, como as ranuras de axuste para parafusos.", + "help.sketch.slot.overall": "Debuxa unha ranura recta dun ancho dado desde un extremo exterior ata o outro. Úsao cando importa a lonxitude total da ranura.", + "help.sketch.spline.control_point": "Debuxa unha spline cúbica definida por puntos de control polos que non pasa. Úsao para curvas suaves e fluídas que moldeas movendo os puntos.", + "help.sketch.spline.control_point_5": "Debuxa unha spline de grao 5 definida por puntos de control. Úsao onde as curvas deben unirse cunha curvatura máis suave, por exemplo en superficies estilizadas visibles.", + "help.sketch.spline.fit_point": "Debuxa unha spline suave que pasa por cada punto que coloques, aberta ou pechada. Úsao para calcar unha forma a través de puntos coñecidos.", + "help.sketch.spline3d": "Debuxa unha curva suave no espazo a través de puntos, co Bosquexo 3D activado. Úsao para traxectorias de varrido e tendidos de cable que saen do plano do bosquexo.", + "help.sketch.spun_profile": "Crea no plano do bosquexo o contorno que describe un corpo ao xirar arredor dun eixe. Úsao para obter a silueta dunha peza torneada para unha revolución ou un debuxo.", + "help.sketch.text": "Escribe texto no bosquexo cunha fonte, altura e ángulo escollidos; as súas palabras poden seguir parámetros. Extrúeo ou grávao en relevo para etiquetas e letreiros.", + "help.sketch.transform": "Escala, xira e move xeometría do bosquexo arredor dun punto central nun só paso. Úsao para recolocar e redimensionar unha selección á vez.", + "help.sketch.trim": "Elimina a parte dunha curva entre os cruzamentos máis próximos ao redor de onde fas clic. Úsao para limpar liñas solapadas ata obter un perfil pechado.", "help.solid.align": "Move os corpos seleccionados para que un punto, cara ou plano deles coincida cun punto, cara ou plano doutro lugar. Úsao para colocar unha peza contra outra sen medir o desprazamento.", + "help.solid.boundary_fill": "Crea sólidos a partir das rexións delimitadas por corpos ferramenta solapados, enchendo só as rexións que escollas. Úsao para construír unha forma a partir do espazo que varios corpos limitan xuntos.", + "help.solid.bounding_solid": "Crea unha caixa que envolve os corpos seleccionados, cunha marxe opcional. Úsao para ver o tamaño total dunha peza, ou como bloque en bruto do que se mecaniza.", + "help.solid.box": "Crea unha caixa a partir dunha lonxitude, largura e altura, desde un punto de esquina ou centrada nun punto. Úsao para un bloque rápido, un marcador de posición ou un corpo no que cortar operacións.", "help.solid.chamfer": "Corta as arestas seleccionadas cun bisel plano da distancia que indiques. Úsao para romper arestas vivas ou para facilitar a entrada dun pasador ou dun parafuso.", + "help.solid.coil": "Crea un corpo helicoidal, como unha mola, a partir dun diámetro, voltas, altura ou paso e unha sección redonda ou cadrada. Úsao para molas, bobinas e cristas helicoidais.", "help.solid.combine": "Une corpos, córtaos dun corpo de destino ou conserva só o volume que comparten, e pode manter os corpos ferramenta. Úsao para fusionar pezas, para cortar unha forma con outra ou para atopar onde se solapan dous corpos.", + "help.solid.cone": "Crea un cono a partir dun círculo de base e unha altura, ou un tronco de cono cando a parte superior tamén ten raio. Úsao para conicidades, funís, avellanados cónicos e puntas.", + "help.solid.cylinder": "Crea un cilindro a partir dun raio ou diámetro e unha altura, apoiado nun punto ao longo dun eixe. Úsao para eixes, pasadores, saíntes e pezas redondas en bruto.", "help.solid.draft": "Inclina as caras seleccionadas segundo o ángulo que indiques, medido desde un plano neutro. Úsao en pezas moldeadas ou fundidas para que saian do molde.", + "help.solid.emboss": "Levanta perfís do bosquexo, como texto ou un logotipo, fóra dunha cara, ou húndeos nela. Úsao para letreiros, etiquetas e marcas en caras planas ou redondas.", "help.solid.extrude": "Empurra un perfil de bosquexo cara ao 3D ao longo dunha distancia, engadindo ou quitando material. Úsao para converter un contorno plano nun bloque, un saínte ou unha cavidade.", "help.solid.fillet": "Arredonda as arestas seleccionadas cunha transición circular suave do raio que indiques. Úsao para suavizar arestas vivas por resistencia, comodidade ou estética.", "help.solid.hole": "Fai un furado redondo do diámetro que indiques nun punto escollido dunha cara. Úsao para folgas de parafusos, pasadores e outras aberturas redondas sinxelas.", @@ -1425,20 +1637,60 @@ "help.solid.move": "Move ou xira os corpos seleccionados, ou unha copia deles, segundo a distancia e o ángulo que indiques. Úsao para recolocar un corpo ou para crear un segundo noutro lugar.", "help.solid.offset_face": "Empurra as caras seleccionadas cara a dentro ou cara a fóra segundo a distancia que indiques, e as caras que as rodean seguen o movemento. Úsao para engrosar unha parede, afondar un rebaixe ou alzar un saínte sen editar o bosquexo.", "help.solid.pattern.circular": "Repite os obxectos seleccionados arredor dun eixe, co número de copias e o ángulo total que indiques. Úsao para circunferencias de parafusos, radios, aletas e dentes.", + "help.solid.pattern.path": "Copia operacións ou corpos ao longo dunha traxectoria de bosquexo, por cantidade e separación. Úsao para furados ao longo dunha aresta curva ou pezas ao longo dun carril.", "help.solid.pattern.rectangular": "Repite os obxectos seleccionados nunha ou en dúas direccións co número de copias e o espazado que indiques. Úsao para filas e grellas de furados, nervios, pasadores ou pés.", + "help.solid.pipe": "Crea un tubo redondo ao longo dunha traxectoria de curvas de bosquexo, macizo ou oco cun espesor de parede. Úsao para tubos, fíos, cables e carrís.", "help.solid.press_pull": "Engade ou retira material ao arrastrar unha cara, ou arredonda as arestas ao arrastralas. Úsao para facer cambios rápidos directamente sobre o modelo.", "help.solid.remove": "Quita os corpos seleccionados do deseño desde este punto da liña de tempo; os pasos anteriores seguen conservándoos. Úsao para descartar un corpo auxiliar, como unha ferramenta de corte que xa non fai falta.", "help.solid.replace_face": "Move as caras planas seleccionadas a un plano ou a unha cara paralela, e o corpo medra ou encolle para chegar a el. Úsao para deixar unha cara a ras doutra peza ou dun plano de construción.", "help.solid.revolve": "Fai xirar un perfil de bosquexo arredor dun eixe para crear un sólido redondo. Úsao para eixes de transmisión, pomos, cuncas e calquera peza redonda arredor dunha liña central.", + "help.solid.rib": "Engade unha parede delgada ao longo dunha curva de bosquexo aberta, chea ata as seguintes caras do corpo. Úsao para reforzar paredes e esquinas, por exemplo en pezas moldeadas ou impresas.", "help.solid.scale": "Fai os corpos seleccionados máis grandes ou máis pequenos segundo un factor, igual en todas as direccións ou un por eixe. Úsao para cambiar o tamaño dunha forma importada ou para compensar a contracción.", "help.solid.shell": "Baleira un sólido deixando paredes co espesor que indiques, e pode abrir as caras que escollas. Úsao para caixas, tapas e outras pezas de parede fina.", + "help.solid.sphere": "Crea unha esfera a partir dun raio ou diámetro arredor dun punto central. Úsao para bólas, pomos e extremos arredondados.", "help.solid.split_body": "Corta un corpo en varios corpos cun plano, a cara doutro corpo ou unha liña de bosquexo. Úsao para facer pezas que se imprimen, mecanizan ou colorean por separado.", "help.solid.split_face": "Divide as caras seleccionadas segundo un plano, a cara doutro corpo ou curvas de bosquexo, sen cortar o corpo. Úsao para dar a unha parte dunha cara o seu propio ángulo de desmoldaxe, cor ou desprazamento.", "help.solid.sweep": "Arrastra un perfil de bosquexo ao longo dunha traxectoria para crear un sólido. Úsao para tubos, carrís, asas e calquera peza de sección constante que siga unha curva.", + "help.solid.thread": "Engade unha rosca métrica ISO a unha cara cilíndrica, como rosca cosmética ou cortada no corpo. Úsao para parafusos, furados roscados e pezas impresas con rosca.", + "help.solid.torus": "Crea un anel a partir dun raio maior arredor do seu eixe e un raio menor para a súa sección redonda. Úsao para xuntas tóricas, asas e contas redondas.", + "help.solid.web": "Engade unha parede delgada ao longo de cada curva de bosquexo aberta, cada unha chea ata as seguintes caras do corpo. Úsao para unha grella de paredes de apoio dentro de carcasas e bandexas.", + "help.surface.blend": "Crea unha superficie que une as arestas de dous corpos, tanxente ou continua en curvatura con ambos. Úsao para unir suavemente dúas superficies a través dun oco.", + "help.surface.boundary": "Crea unha superficie que enche un bucle de dúas a catro curvas de bosquexo, ou unha rede de curvas. Úsao para paneis de forma libre definidos polas súas arestas.", + "help.surface.extend": "Alonga unha superficie máis alá das súas arestas, nunha distancia ou ata un plano ou unha cara. Úsao para que as superficies se solapen antes de recortalas ou coselas.", + "help.surface.extrude": "Empurra curvas ou perfís do bosquexo ao longo dunha distancia ata formar unha superficie, só paredes, sen tapas. Úsao para paredes delgadas e para ferramentas de corte ou división.", + "help.surface.fair": "Suaviza o interior de caras de forma libre mentres as súas arestas quedan onde están. Úsao para igualar bultos e reflexos ondulados nunha superficie.", + "help.surface.loft": "Crea unha superficie que pasa por dúas ou máis seccións en orde, sen tapas. Úsao para carcasas e transicións entre formas diferentes.", + "help.surface.match": "Reconstrúe a aresta dunha superficie para que coincida coa aresta doutro corpo, con tanxencia ou curvatura coincidentes. Úsao para pechar suavemente unha xunta entre dúas superficies.", + "help.surface.offset": "Crea unha copia de caras a unha distancia ao longo das súas normais, como un corpo de superficie novo. Úsao para coberturas, folgas ou o outro lado dunha parede.", + "help.surface.patch": "Enche unha rexión do bosquexo ou un bucle pechado de arestas cunha cara, plana ou suavemente curva. Úsao para pechar furados nun corpo de superficie.", + "help.surface.reverse_normal": "Dá a volta ás caras dun corpo de superficie, de modo que o seu exterior mire ao outro lado. Úsao antes de engrosar ou coser cando as caras apuntan cara a dentro.", + "help.surface.revolve": "Xira curvas ou perfís do bosquexo arredor dun eixe ata formar unha superficie, sen tapas. Úsao para carcasas redondas como pantallas de lámpada, cuncas e boquillas.", + "help.surface.ruled": "Crea unha tira de rectas a partir dunha cadea de arestas, ao longo da normal da cara, da tanxente ou nun ángulo. Úsao para paredes de desmoldaxe, zonas de saída e superficies de partición.", + "help.surface.stitch": "Cose corpos de superficie ao longo das súas arestas compartidas, ata formar un sólido cando se pechan. Úsao para converter un conxunto de superficies nun só corpo.", + "help.surface.sweep": "Move curvas ou perfís do bosquexo ao longo dunha traxectoria ata formar unha superficie, sen tapas. Úsao para canaletas, molduras e outras formas delgadas que seguen unha traxectoria.", + "help.surface.thicken": "Dá espesor a un corpo de superficie e convérteo nun sólido. Úsao para facer pezas imprimibles ou mecanizables a partir de superficies.", + "help.surface.trim": "Corta unha superficie cun plano, outra superficie ou curvas de bosquexo, e conserva o lado que escollas. Úsao para cortar superficies á súa forma antes de coser.", + "help.surface.unstitch": "Separa caras dun corpo en corpos de superficie propios; un sólido desfaise en superficies. Úsao para retocar caras soas dun corpo.", + "help.surface.untrim": "Elimina furados dunha cara, ou restaura o seu contorno exterior aos límites naturais da superficie. Úsao para recuperar a superficie completa despois dun recorte.", "help.third_party": "Licenzas de terceiros", "help.timeline.compute_all": "Reconstrúe todas as operacións da liña de tempo desde o principio. Úsao para actualizar o modelo e ver que operacións fallan.", "help.timeline.delete": "Elimina as operacións seleccionadas da liña de tempo; ao eliminar un bosquexo tamén se eliminan as operacións construídas a partir del. Úsao para quitar un paso que xa non se quere.", + "help.timeline.dependents": "Lista as operacións que se eliminarían con unha operación, e as que fallarían sen ela. Úsao antes de eliminar ou cambiar unha operación.", "help.timeline.play": "Reproduce as operacións restantes unha a unha, cunha pausa de medio segundo despois de cada reconstrución. Executa este comando de novo ou preme Escape para deter a reprodución; Ir ao final salta de inmediato.", + "help.view.apply": "Xira a vista a unha dirección estándar como frontal, superior ou isométrica, ou a unha vista con nome gardada. Úsao para mirar o modelo desde un ángulo fixo.", + "help.view.delete": "Elimina unha vista con nome gardada. O modelo non cambia.", + "help.view.fit": "Fai zoom e desprázase para que todo o modelo, ou a selección, encha a vista. Úsao cando o modelo saíu da vista.", + "help.view.grid": "Mostra ou oculta a grella na ventá gráfica. Úsao como guía de tamaño e orientación ao modelar.", + "help.view.ground_shadow": "Mostra ou oculta a sombra do modelo no chan. Úsao para xulgar máis facilmente a altura e a posición.", + "help.view.home": "Devolve a vista á dirección de inicio e axusta o modelo. Úsao para volver rapidamente a unha vista coñecida.", + "help.view.list": "Lista as vistas con nome gardadas no deseño. Úsao para atopar unha vista que gardaches antes.", + "help.view.navigate": "Xira, despraza ou aplica zoom á vista por pasos, como un cuarto de volta á esquerda. Úsao para mover a cámara con precisión en vez de arrastrar.", + "help.view.navigation_bar": "Mostra ou oculta a barra de navegación na parte inferior da ventá gráfica. Ocúltaa para ter máis espazo e móstraa para acceder co rato ás ferramentas da vista.", + "help.view.rename": "Dálle un nome novo a unha vista con nome gardada. A vista en si non cambia.", + "help.view.save": "Garda a posición actual da cámara como unha vista con nome no deseño. Úsao para volver máis tarde ao mesmo ángulo, por exemplo para capturas de pantalla.", + "help.view.spin": "Orbita a vista arredor do modelo de forma continua ata o seguinte clic ou tecla. Úsao para presentar un modelo desde todos os lados.", + "help.view.tool": "Fixa unha ferramenta de cámara, órbita, desprazamento ou zoom, de modo que arrastrar co botón esquerdo do rato move a vista ata que a desactives. Úsao nun panel táctil ou sen botón central do rato.", + "help.view.visual_style": "Cambia como se debuxa o modelo: sombreado con ou sen arestas, arames ou con arestas ocultas. Usa arames para ver o interior e sombreado para un aspecto limpo.", "history.sketch_under_constrained": "Subrestrinxido ({count} graos de liberdade)", "history.sketch_under_constrained_one": "Subrestrinxido ({count} grao de liberdade)", "hole_presets.confirm_delete": "Confirmar a eliminación", @@ -1615,6 +1867,10 @@ "material.selection.mixed": "Materiais diferentes", "measure.at": "En", "measure.item": "Elemento {number}", + "measure.quick_copied": "Medición copiada", + "measure.quick_copy": "Fai clic para copiar a medición", + "measure.quick_edges": "{count} arestas", + "measure.quick_faces": "{count} caras", "messages.ago_h": "hai {n} h", "messages.ago_min": "hai {n} min", "messages.ago_s": "hai {n} s", @@ -1767,6 +2023,11 @@ "not a move": "non é un movemento", "not a number": "non é un número", "open a sketch first": "abre primeiro un bosquexo", + "parameters.apply_live": "Aplicar cada cambio ao saír do seu campo", + "parameters.apply_live_tip": "Desactivado: os cambios agardan a Aplicar ou Aceptar, e entón o deseño reconstrúese unha soa vez para todos. Cancelar ou Esc descártaos.", + "parameters.held": "{count} cambios pendentes de Aplicar", + "parameters.held_hint": "Edita expresións e unidades. Os cambios agardan a Aplicar ou Aceptar.", + "parameters.held_one": "1 cambio pendente de Aplicar", "parts.automatic_name": "automático", "parts.dimension.across_flats": "Ancho entre planos", "parts.dimension.bore": "Diámetro do furado", @@ -1789,6 +2050,8 @@ "parts.family.set_screw": "Parafuso sen cabeza de hexágono interior, punta plana", "parts.family.socket_head_cap_screw": "Parafuso de cabeza cilíndrica con hexágono interior", "parts.family.washer": "Arandela plana", + "parts.holes": "Furados (opcional)", + "parts.placement_hint": "escolle un ou máis furados para asentar unha peza en cada un, ou Aceptar colócaa na orixe", "pattern.alignment_2": "Aliñamento 2", "pattern.alignment_hint": "O número inclúe o orixinal. Centrar equilibra as copias; Inverter escolle o lado da copia extra cando o total é par.", "pattern.direction_1": "Dirección 1", @@ -1955,6 +2218,8 @@ "prefs.origin_plane_names": "Nomes dos planos de orixe", "prefs.origin_plane_names_note": "Nomes que se mostran no navegador, nos diálogos e nas listas. {top} é XY, {front} é XZ e {right} é YZ, co eixe Z cara arriba. Os comandos, os scripts e os deseños gardados manteñen XY, XZ e YZ.", "prefs.overall_speed": "Velocidade xeral", + "prefs.parameter_list_rows": "Filas da lista de parámetros", + "prefs.parameter_list_rows_tip": "A frecha cara abaixo nun campo de valor baleiro lista os parámetros, primeiro os favoritos, ata este número. Un número menor mantén áxil a lista nun deseño grande.", "prefs.persistent_ribbon_pressed": "Resaltar as ordes activas da cinta", "prefs.persistent_ribbon_pressed_help": "Resalta as ordes da cinta mentres os seus efectos están activados. Desactívao para manter o aspecto neutro e a axuda das ordes anteriores.", "prefs.perspective_fov": "Campo de visión", @@ -1984,6 +2249,8 @@ "prefs.quality_fastest": "máis rápido", "prefs.quality_slowest": "máis lento", "prefs.quality_smoother": "máis suave", + "prefs.quick_measure": "Medición rápida na esquina da vista", + "prefs.quick_measure_note": "O tamaño da selección na esquina inferior dereita da vista: a lonxitude dunha aresta, ou Ø e R se é redonda, a área dunha cara, a posición dun punto, a distancia e o ángulo entre dous elementos, o total de varias arestas ou caras. Un clic cópiao.", "prefs.render_previous_geometry_budget": "Usar o orzamento de xeometría anterior da vista previa de Render", "prefs.render_previous_geometry_budget_help": "Restaura o límite fixo de xeometría anterior (512 MiB en escritorio de 64 bits, 128 MiB noutros casos) en lugar do orzamento baseado no dispositivo.", "prefs.reports_saved": "{n} gardados", @@ -2029,6 +2296,8 @@ "prefs.sketch_tab_always_note": "A lapela Bosquexo permanece na cinta de opcións, xusto despois de Modelo, mentres non haxa ningún bosquexo aberto. As súas ferramentas de debuxo inician entón un bosquexo no plano ou na cara seleccionados, ou no plano que escollas a continuación, coa ferramenta lista nel.", "prefs.sketch_tracking_guides": "Guías de aliñamento de centro e punto medio", "prefs.sketch_tracking_guides_note": "Pasa o rato sobre o centro dun círculo ou o punto medio dunha liña recta e despois móvete en horizontal ou en vertical para mostrar unha guía punteada temporal.", + "prefs.sketch_tracking_points": "Guías de aliñamento desde extremos e esquinas ao debuxar", + "prefs.sketch_tracking_points_note": "Mentres unha ferramenta de debuxo agarda un punto, ao pasar o cursor sobre o extremo dunha liña ou dun arco, unha esquina de rectángulo ou un punto libre aparece a mesma guía de puntos temporal que para un centro ou un punto medio. Desactivado: só centros e puntos medios.", "prefs.sketching": "Creación de bosquexos", "prefs.snap_drawing_note": "Ao debuxar ou arrastrar un punto de bosquexo.", "prefs.source": "Fonte", @@ -2056,6 +2325,16 @@ "prefs.theme_follow_off": "Desactivado", "prefs.theme_follow_stop": "Deixar de seguir", "prefs.theme_import_hint": "Fai clic para escoller un ficheiro de tema, solta un aquí ou preme Ctrl+V (Cmd+V nun Mac) con un tema copiado", + "prefs.theme_option_opacity": "Opacidade", + "prefs.theme_option_opacity_help": "Axusta a opacidade dos paneis flotantes. Un mínimo de lexibilidade protexe o texto sobre as cores da ventá gráfica.", + "prefs.theme_option_tab_colours": "Cores das lapelas", + "prefs.theme_option_tab_colours_help": "Usa as cores de panel e de ventá gráfica deste tema para cada lapela da cinta.", + "prefs.theme_option_tab_fade_ms": "Transición entre lapelas", + "prefs.theme_option_tab_fade_ms_help": "Funde as cores do panel e da ventá gráfica ao cambiar de lapela. Cero cambia ao instante.", + "prefs.theme_options_badge": "Opcións", + "prefs.theme_options_more": "Máis · {count}", + "prefs.theme_options_reset": "Restablecer", + "prefs.theme_options_unavailable": "Non dispoñible neste dispositivo", "prefs.theme_paste": "Pegar tema", "prefs.theme_paste_none": "O portapapeis non contén ningún tema de SolidCraft", "prefs.timeline_half_hour": "−30 min", @@ -2077,6 +2356,8 @@ "prefs.under_cursor": "Baixo o cursor", "prefs.units_precision": "Unidades e precisión", "prefs.updates": "Actualizacións", + "prefs.value_boxes_in_view": "Mostrar os valores xunto ás súas asas na vista", + "prefs.value_boxes_in_view_tip": "O valor dunha orde (Distancia, Raio…) nunha caixa xunto á súa asa, sobre o modelo. Desactivado: escríbese na xanela da orde, que toma o teclado despois de escoller, e nada cobre o modelo.", "prefs.view": "Comportamento da vista", "prefs.view_confirm_corner": "Aceptar e Cancelar na esquina da vista", "prefs.view_confirm_corner_note": "Mentres hai un diálogo ou un bosquexo aberto, unha marca de verificación e unha cruz na parte superior dereita da vista fan o mesmo que Aceptar e Cancelar do diálogo, ou finalizan e cancelan o bosquexo. O navegador da vista desprázase cara abaixo, debaixo delas.", @@ -2086,6 +2367,12 @@ "prefs.viewport_background": "Fondo", "prefs.viewport_background_colour": "Cor", "prefs.viewport_background_theme": "Degradado do tema", + "prefs.viewport_section_status": "Mostrar o estado da sección na ventá gráfica", + "prefs.viewport_section_status_help": "Mostra o axuste da sección na ventá gráfica. Esc e o botón activo da cinta desactívano. Desactiva para manter os controis de sección anteriores.", + "prefs.viewport_status_details": "Detalles da ventá gráfica en directo", + "prefs.viewport_status_details_help": "Edita os axustes activos de análise e de sección en tarxetas de detalles estándar. Desactiva para restaurar as lendas de análise de só lectura e o diálogo Sección.", + "prefs.viewport_status_summary": "Agrupar os elementos de estado da ventá gráfica", + "prefs.viewport_status_summary_help": "Agrupa tres ou máis elementos de estado da ventá gráfica nun só distintivo de resumo. Desactiva para manter todos os elementos na fila completa.", "prefs.wheel": "Roda", "prefs.wheel_trackpad": "Roda e panel táctil", "prefs.workspace": "Espazo de traballo", @@ -2964,7 +3251,9 @@ "render.output_ready": "Listo para renderizar", "render.output_run_check": "Executar a comprobación", "render.output_size_limit": "{method}: ata {dimension} px por lado e {pixels} píxeles. Tamaño máximo que cabe: {width} × {height}.", + "render.output_size_method_profile": "Para renderizar {width} × {height}, escolle {method} co perfil de recursos de CPU {profile}, ou un tamaño menor.", "render.output_size_ok": "Tamaño correcto · {width} × {height} px", + "render.output_size_profile": "Para renderizar {width} × {height}, escolle o perfil de recursos de CPU {profile}, ou un tamaño menor.", "render.output_swap_orientation": "Intercambiar largura e altura", "render.output_tools_inspector": "Edita o tamaño e o formato no inspector de Saída.", "render.output_view_image": "Ver a imaxe", @@ -3177,6 +3466,7 @@ "render.size_custom": "Personalizado…", "render.size_full_hd": "Full HD", "render.size_hd": "HD", + "render.size_needs_profile": "{size} · necesita {profile}", "render.size_presets": "Axustes predefinidos de tamaño", "render.size_presets_pending": "Chegará co editor de Saída", "render.size_preview": "Vista previa", @@ -3464,6 +3754,24 @@ "sheet.flange_type_contour": "Contorno", "sheet.flange_type_edge": "Aresta", "sheet.no_body": "non hai ningún corpo de chapa: crea un con Pestana > Base, ou converte unha placa en chapa con Converter en chapa (escolle a súa cara plana grande)", + "sheet.personal_rules.confirm_delete": "Confirmar a eliminación", + "sheet.personal_rules.delete": "Eliminar", + "sheet.personal_rules.delete_prompt": "Eliminar esta regra persoal? As regras que xa están en deseños non cambian.", + "sheet.personal_rules.full": "A biblioteca persoal xa contén 64 regras.", + "sheet.personal_rules.invalid_name": "Introduce un nome curto sen saltos de liña nin caracteres de control.", + "sheet.personal_rules.invalid_values": "Completa os valores da regra: cada un debe poder calcularse neste deseño, cun espesor positivo e un factor K de 0 a 1.", + "sheet.personal_rules.missing": "A regra persoal seleccionada xa non está dispoñible.", + "sheet.personal_rules.name": "Nome da regra", + "sheet.personal_rules.name_exists": "Xa existe unha regra persoal con este nome. Usa un nome diferente.", + "sheet.personal_rules.name_hint": "Un nome único", + "sheet.personal_rules.overwrite": "Gardar os cambios na regra persoal seleccionada", + "sheet.personal_rules.rename": "Renomear", + "sheet.personal_rules.rule": "Regra persoal", + "sheet.personal_rules.save": "Gardar como nova", + "sheet.personal_rules.scope": "Gárdase no teu perfil para todos os deseños.", + "sheet.personal_rules.select": "Selecciona unha regra", + "sheet.personal_rules.title": "Regras persoais", + "sheet.personal_rules.unsupported": "Esta regra ten valores que esta versión non pode usar.", "shortcut.json_error": "os parámetros deben estar en formato JSON: {error}", "shortcut.no_command": "non existe o comando `{id}`; o texto anterior ao JSON debe ser un ID de comando", "shortcut.pin": "Fixar na parte superior da caixa S", @@ -3500,6 +3808,7 @@ "sketch_text.align_right": "Dereita", "sketch_text.bold": "Negra", "sketch_text.builtin_font": "{family} (integrado)", + "sketch_text.formula": "Comeza con = para construír as palabras a partir de parámetros: ='W' + number(Width)", "sketch_text.italic": "Cursiva", "sketch_text.loading_fonts": "Buscando os tipos de letra instalados…", "sketch_text.on_path": "O texto segue a súa traxectoria: a curva colócao", @@ -3640,6 +3949,8 @@ "ui.3d_mouse_section": "Rato 3D", "ui.3d_mouse_show_in_view": "Mostrar o rato 3D na vista 3D", "ui.3d_mouse_show_in_view_tip": "Unha imaxe do teu rato 3D na esquina inferior esquerda que se move cando o moves.", + "ui.3d_mouse_sketch_orbit": "Xirar os bosquexos co rato 3D", + "ui.3d_mouse_sketch_orbit_tip": "O rato 3D tamén xira a vista mentres hai un bosquexo aberto e visto de fronte, como pode facelo o rato. Desactivado: despraza o bosquexo e aplícalle zoom ata que inclines a vista.", "ui.3d_mouse_skip": "O meu dispositivo non fai isto", "ui.3d_mouse_skip_tip": "Omite este movemento nos dous sentidos; a calibración déixao fóra.", "ui.3d_mouse_speed": "Velocidade do rato 3D", @@ -3708,6 +4019,7 @@ "ui.agent_chat_auth_terminal": "Executa isto nun terminal e despois preme Conectar de novo:", "ui.agent_chat_auth_title": "Iniciar sesión no axente", "ui.agent_chat_cancel": "Cancelar", + "ui.agent_chat_cannot_start": "Instalado, pero non se inicia: {error}", "ui.agent_chat_changes": "Cambios: {n}, un só paso de desfacer", "ui.agent_chat_changes_n": "{n} cambios", "ui.agent_chat_changes_of": "{n}/{limit} cambios", @@ -3800,9 +4112,12 @@ "ui.agent_chat_queued_stopped": "Na cola (detido, sen enviar)", "ui.agent_chat_refresh": "Actualizar", "ui.agent_chat_refused": "Non permitido ao axente: {reason}", + "ui.agent_chat_reinstall": "{agent} está instalado ({program}), pero non se iniciou cando SolidCraft o comprobou: {error}\nPode que a instalación estea incompleta ou feita para outro tipo de ordenador. Instálao de novo con `{install}`, comproba que mostra a súa versión nun terminal e preme Actualizar.", "ui.agent_chat_remove": "Quitar da cola", "ui.agent_chat_remove_file": "Quitar desta mensaxe", "ui.agent_chat_remove_imported": "Quitar desta mensaxe; a peza queda no deseño (Desfacer quítaa)", + "ui.agent_chat_reply_cut_off": "A resposta chegou ao seu límite de lonxitude antes de acabar, así que pode estar incompleta. Pídelle ao axente que continúe, ou que dea unha resposta máis curta.", + "ui.agent_chat_reply_declined": "O modelo rexeitou responder a esta mensaxe.", "ui.agent_chat_retry": "Reintentar", "ui.agent_chat_right": "Dereita", "ui.agent_chat_save": "Gardar", @@ -3861,12 +4176,17 @@ "ui.ai_copy_suffix": "copia", "ui.ai_custom_url": "URL personalizado", "ui.ai_default": "Predeterminado", + "ui.ai_edit": "Editar", + "ui.ai_edit_agent_title": "Editar axente", + "ui.ai_edit_gone": "Este axente quitouse, así que non hai nada que gardar.", + "ui.ai_edit_tip": "Cambia o nome, a orde ou o punto de acceso deste axente. A clave gardada segue oculta.", "ui.ai_http_busy": "o punto de acceso está ocupado ou superou o seu límite; téntao de novo máis tarde", "ui.ai_http_key": "o punto de acceso rexeitou a clave: compróbaa en Preferencias > IA", "ui.ai_http_not_found": "non existe ese punto de acceso ou modelo: comproba o URL base e o nome do modelo", "ui.ai_http_too_large": "a conversa é demasiado grande para este punto de acceso; comeza un chat novo", "ui.ai_key": "Clave da API", "ui.ai_key_hint": "opcional; gárdase no chaveiro do sistema", + "ui.ai_key_keep_hint": "déixao baleiro para manter a clave gardada", "ui.ai_key_needs_https": "A clave só se envía por HTTPS ou a este ordenador.", "ui.ai_key_note": "Gárdase no chaveiro do sistema, nunca nos axustes de SolidCraft. Déixaa baleira para un servidor local.", "ui.ai_keychain_error": "O chaveiro do sistema rexeitouno: {error}", @@ -3898,6 +4218,7 @@ "ui.ai_request_timeout": "Tempo límite da solicitude", "ui.ai_request_timeout_hint": "Canto pode tardar unha solicitude ao modelo neste punto de acceso, de 30 s a unha hora. Aumenta o valor para un modelo local lento.", "ui.ai_reset": "Restablecer", + "ui.ai_save": "Gardar", "ui.ai_settings": "Axustes de IA…", "ui.ai_show_features": "Mostrar as funcións de IA", "ui.ai_show_features_note": "O chat co axente: un panel onde un axente de programación que xa usas, ou un punto de acceso de modelo que escollas, traballa no deseño aberto. Desactivado: sen botón de IA, atallo, entrada de menú nin panel, e non se busca nin se inicia nada.", @@ -4071,6 +4392,40 @@ "ui.design_properties.project": "Proxecto", "ui.design_properties.revision": "Revisión", "ui.design_saved": "Gardado en {path}", + "ui.details_access_caption": "As caras mostran se unha ferramenta pode chegar a elas desde a dirección escollida.", + "ui.details_angle": "Ángulo", + "ui.details_continuity_caption": "Edita a xunta seleccionada. As outras xuntas manteñen os seus propios axustes.", + "ui.details_continuity_stale": "Xeometría anterior: volve executar a análise para editar ou encadrar esta xunta.", + "ui.details_copy_list": "Copiar a lista", + "ui.details_curvature": "Tolerancia de curvatura", + "ui.details_curvature_caption": "As caras coloréanse segundo o seu raio de curvatura local.", + "ui.details_direction": "Dirección da ferramenta", + "ui.details_draft_caption": "As caras coloréanse segundo o seu ángulo coa dirección de extracción.", + "ui.details_edit_seam": "Editar a xunta", + "ui.details_fixture": "Mostra de compoñente — só datos de exemplo", + "ui.details_fixture_caption": "Mostra de compoñente con valores ilustrativos; non é un resultado do modelo.", + "ui.details_fixture_problem": "Exemplo de resultado cun problema", + "ui.details_fixture_readout": "Exemplo de valor medido", + "ui.details_flip": "Inverter", + "ui.details_gap": "Separación", + "ui.details_hatch": "Raiar as caras cortadas", + "ui.details_jump": "Salto de curvatura", + "ui.details_key": "Lenda", + "ui.details_offset": "Desprazamento", + "ui.details_plane": "Plano", + "ui.details_pull": "Dirección de extracción", + "ui.details_radius": "Raio", + "ui.details_readout": "Lectura", + "ui.details_samples": "Mostras", + "ui.details_seam": "{body} · Aresta {edge}", + "ui.details_settings": "Axustes", + "ui.details_sharp_gap": "Quebra ou separación", + "ui.details_show_all": "Mostrar todos ({count})", + "ui.details_smooth": "Suave", + "ui.details_stripes": "Raias", + "ui.details_target": "Encadrar a xeometría", + "ui.details_worst_seams": "Peores xuntas", + "ui.details_zebra_caption": "As raias reflectidas mostran con que suavidade se unen as caras.", "ui.diameter": "Diámetro", "ui.dimension": "Cota", "ui.direct_interaction": "Interacción directa", @@ -4131,6 +4486,7 @@ "ui.equation_y_of_x": "y(x)", "ui.equivalent_radius": "Raio equivalente", "ui.expand": "Expandir", + "ui.explode_needs_two_components": "A vista explosionada separa compoñentes, así que o deseño necesita dous ou máis (Compoñente novo, Inserir deseño ou Crear compoñentes a partir de corpos)", "ui.exportDxf": "Exportar DXF…", "ui.export_ellipsis_keymap": "Exportar…", "ui.export_keymap_tip": "Teclas, favoritos da caixa S e a barra de ferramentas, como un ficheiro", @@ -4419,12 +4775,17 @@ "ui.plugins_skipped": "Non se puido ler {count} cartafol(es).", "ui.plugins_version": "{version}", "ui.point": "Punto", + "ui.point_cloud_add_filter": "Engadir filtro…", "ui.point_cloud_auto_range": "Intervalo automático", "ui.point_cloud_color_by": "Colorear por", "ui.point_cloud_file_colors": "Cores do ficheiro", + "ui.point_cloud_filter_by": "Filtrar por", "ui.point_cloud_height_high": "Máis alto", "ui.point_cloud_height_low": "Máis baixo", + "ui.point_cloud_legend_filter": "Mostrado e oculto na lenda de arriba", "ui.point_cloud_no_file_colors": "O ficheiro non ten cores", + "ui.point_cloud_pause": "Pausa", + "ui.point_cloud_play": "Reproducir", "ui.point_cloud_single_color": "Cor única", "ui.point_cloud_size": "Tamaño do punto", "ui.point_cloud_units": "Unidades do ficheiro", @@ -4453,6 +4814,7 @@ "ui.properties.inertia_y": "Iyx, Iyy, Iyz", "ui.properties.inertia_z": "Izx, Izy, Izz", "ui.properties.measuring": "Medindo…", + "ui.properties.measuring_bodies": "Medindo… {done} de {total} corpos", "ui.properties.measuring_failed": "A medición detívose antes de rematar.", "ui.properties.principal_axis": "Eixe principal {number}", "ui.properties.principal_moments": "Momentos principais", @@ -4520,6 +4882,7 @@ "ui.selectOther": "Seleccionar outro…", "ui.selectTangentChain": "Seleccionar cadea tanxente", "ui.select_all": "Seleccionar todo", + "ui.select_components_by_face": "Seleccionar compoñentes (unha cara escolle o seu compoñente)", "ui.selection": "Selección", "ui.selection_modifiers": "Modificadores de selección", "ui.selection_shift_add_only": "Maiús+clic engade sen quitar", @@ -4561,6 +4924,11 @@ "ui.sketch_intersect_selection_filter": "Filtro de selección", "ui.sketch_modifiers": "Modificadores de bosquexo", "ui.sketch_project_geometry": "Xeometría", + "ui.sketch_report.all_constrained": "Todo está totalmente restrinxido.", + "ui.sketch_report.dof": "Graos de liberdade", + "ui.sketch_report.loose": "Non totalmente restrinxido", + "ui.sketch_report.status": "Estado", + "ui.sketch_report.title": "Informe do bosquexo", "ui.sketch_scale": "Escala do bosquexo", "ui.sketches": "Bosquexos", "ui.slice": "Laminar", @@ -4670,7 +5038,14 @@ "ui.viewport_status_key": "Mostrar a lenda de {name}", "ui.viewport_status_minimum_radius": "Raio mínimo", "ui.viewport_status_pairs": "{count} pares", + "ui.viewport_status_section_hatch": "{setting} · raiado", + "ui.viewport_status_section_plane": "Plano actual", + "ui.viewport_status_section_tilted": "inclinado · {offset}", "ui.viewport_status_stripes": "{count} raias", + "ui.viewport_status_summary_clear": "Desactivar todo", + "ui.viewport_status_summary_heading": "Nesta vista", + "ui.viewport_status_summary_label": "{name} e {count} elementos máis", + "ui.viewport_status_summary_more": "+{count}", "ui.viewport_theme_follow": "Seguir o tema", "ui.visual_shaded_hidden_edges": "Sombreado con arestas ocultas", "ui.visual_shaded_with_edges": "Sombreado con arestas", diff --git a/assets/locales/he.json b/assets/locales/he.json index c3a0843993..20cbac55a8 100644 --- a/assets/locales/he.json +++ b/assets/locales/he.json @@ -822,6 +822,7 @@ "cmd.appearance_pick_up": "בחירת מראה", "cmd.appearance_replace": "החלפת מראה", "cmd.assembly_bom": "רשימת חלקים", + "cmd.assembly_mass_properties": "תכונות מסה", "cmd.body.print_face": "פאת הדפסה", "cmd.body_rename": "שינוי שם גוף", "cmd.browser_collapse_all": "כיווץ הכול", @@ -1247,6 +1248,7 @@ "cmd.view.render_browser": "דפדפן רינדור", "cmd.view.render_dock": "הזז את עגינת הרינדור", "cmd.view.render_focus": "תצוגת מיקוד רינדור", + "cmd.view.status_details": "פרטי מצב חלון התצוגה", "cmd.view.status_items": "פריטי מצב תצוגה", "cmd.view_ambient_occlusion": "הצללה סביבתית", "cmd.view_angle_readout": "זוויות תצוגה", @@ -1285,8 +1287,14 @@ "color.hex_hint": "#rrggbb", "color.hue": "גוון", "color.linear": "ערכי הצבע מוצגים במרחב לינארי 0-1", + "color.recent": "צבעים אחרונים", + "color.recent_help": "צבעים שבוחרים בכל שדה צבע מופיעים כאן", + "color.saturation": "רוויה (אחוזים)", "color.saturation_value": "רוויה ובהירות", "color.selected": "הצבע שנבחר", + "color.step_down": "צעד למטה", + "color.step_up": "צעד למעלה", + "color.value": "ערך, הבהירות (אחוזים)", "config.column_filter_empty": "אין עמודות תואמות", "contact.components_count": "{count} רכיבים", "contact.enabled_label": "מופעל", @@ -1333,6 +1341,7 @@ "dialog.dimension_name": "מידה: {name}", "dialog.edit_title": "עריכת {title}", "dialog.no_shown_body": "אין גוף מוצג לשינוי: הציגו גוף, או בחרו „גוף חדש”", + "dialog.pattern_sketch_not_object": "בחרו גופים או פיצ'רים: סקיצה אינה אף אחד מהם. חזרו על העקומות שלה בעזרת המערך או השיקוף של הסקיצה עצמה, או בצעו לה שליפה קודם.", "dialog.select": "↖ בחירה", "dialog.selected_count": "↖ {count} נבחרו", "dialog.selection_guidance": "יש לבחור קלט כדי לשנות אותו. לחיצה על אובייקט שנבחר מסירה אותו; גם Ctrl/Cmd+לחיצה מחליפה את מצבו. לאחר בדיקת התצוגה המקדימה יש ללחוץ על אישור.", @@ -1390,21 +1399,168 @@ "from the rule": "לפי הכלל", "help.apache": "רישיון Apache 2.0", "help.appearance.assign": "מתן צבע או מראה של חומר לגופים, לפאות או לרכיבים שנבחרו, בלי לשנות את משקלם. מתאים להבחנה בין חלקים או לתצוגה מקדימה של גימור.", + "help.assembly.bom": "רישום חלקי התכן או הרכיב עם הכמויות שלהם לפי מק״ט, עם אפשרות לשמור את הרשימה כ־CSV או JSON. משמש כרשימת חלקים להזמנה ולהרכבה.", "help.attributions": "ייחוסים (ATTRIBUTION.md)", "help.build": "גרסה {version} · קומיט {commit} · נבנתה ב־{date}", + "help.canvas.decal": "הנחת תמונה על פאה מישורית של המודל, כמו מדבקה. מתאים להצגת תווית, לוגו או דוגמה מודפסת על חלק.", + "help.canvas.edit": "שינוי קנבס: מיקום, גודל, זווית, אטימות, שיקוף, נראות או שם. כך מיישרים תמונת ייחוס עם הסקיצה שמשרטטים מעליה.", + "help.canvas.insert": "הנחת תמונת PNG או JPEG על מישור או פאה כייחוס שקוף למחצה. כך מעתיקים קווי מתאר מתצלום או מציור לסקיצה, כשהתמונה מותאמת למידה ידועה.", + "help.component.break_link": "ניתוק רכיב מקושר מקובץ התכן שלו, תוך השארת הגופים כפי שהם. מתאים כשהחלק אינו אמור עוד להתעדכן בעקבות שינויים בקובץ המקורי.", + "help.component.capture_position": "שמירת המיקומים של חלקים שהוזזו ידנית כמיקומים הקבועים שלהם. מתאים לאחר גרירת חלקים למקומם כשאין מפרק שמחזיק אותם.", + "help.component.create": "הוספת רכיב חדש וריק לתכן, בתוך הרכיב הפעיל אלא אם נבחר רכיב אב אחר. כך מקצים לחלק חדש בהרכבה מקום משלו לגופים ולסקיצות.", "help.component.finish_edit": "שומר את החלק שנערך במקומו, חוזר להרכבה שלו ומעדכן ממנו את ההרכבה. יש להשתמש בו כשהשינויים בחלק המקושר הושלמו.", + "help.component.from_bodies": "הפיכת כל גוף שנבחר לרכיב נפרד שנקרא על שם הגוף. מתאים להפיכת תכן של גופים נפרדים להרכבה שחלקיה ניתנים לחיבור ולהזזה.", + "help.component.insert_design": "הוספת תכן שמור אחר כרכיב מקושר לקובץ שלו, כשהגופים המוצקים ממוקמים כפי שהם בו. כך משתמשים שוב בחלק בהרכבה ומקבלים שינויים מאוחרים יותר באמצעות עדכון קישורים.", + "help.component.properties": "הצגת הנפח, המסה, מרכז המסה ומומנטי האינרציה של רכיב, לפי החומרים של הגופים שבו. מתאים לבדיקת המשקל והאיזון של הרכבה.", + "help.component.revert_position": "החזרת חלקים שהוזזו ידנית למיקומם הקודם וביטול הזזות שמיקומן לא נלכד. כך מבטלים הזזות ניסיוניות בהרכבה.", + "help.component.update_links": "קריאה מחדש של קובצי התכן המקושרים והחלפת גופים שהשתנו. מתאים לאחר עריכת חלק שמשמש בהרכבה זו.", + "help.config.configure": "הוספת פרמטרים, פיצ'רים, חומרים או מראות כעמודות בטבלת התצורות. כך בונים גרסאות של תכן אחד, למשל מידות שונות של חלק.", + "help.config.preview": "בדיקה שאחת משורות טבלת התצורות נבנית מחדש, בלי לשנות את התכן או את ההיסטוריה שלו. מתאים לפני מעבר לגרסה שאינך בטוח בה.", + "help.config.rules": "הצגת הכללים של טבלת התצורות, השורות שלה והשורה הפעילה. כך רואים איך מוגדרות הגרסאות של התכן.", + "help.config.table": "הצגת טבלת התצורות: שורה לכל גרסה, עמודה לכל פרמטר או פיצ'ר, ואם התכן עדיין תואם לשורה הפעילה. מתאים לסקירת הגרסאות של התכן.", "help.connect_note": "יש להפעיל מחדש כל עוזר כדי לטעון את שרת SolidCraft. לכל קובץ נשמר גיבוי בסיומת .bak לפני השינוי.", "help.connect_title": "חיבור עוזרי בינה מלאכותית", + "help.construct.axis.edge": "יצירת ציר עזר לאורך מקצוע ישר או קו של סקיצה. משמש כציר לסיבוב, לשכפול מעגלי או למפרק.", + "help.construct.axis.normal_to_face": "יצירת ציר עזר דרך נקודה, ניצב לפאה. משמש כציר שיוצא ישר מתוך משטח.", + "help.construct.axis.through_cylinder": "יצירת ציר עזר דרך מרכז פאה של גליל, חרוט או טורוס, או מקצוע עגול. מתאים לסיבוב או לשכפול פיצ'רים סביב חלק עגול.", + "help.construct.axis.two_planes": "יצירת ציר עזר במפגש של שני מישורים או שתי פאות שטוחות. מתאים כשהציר הדרוש הוא הקו שבין שתי פאות.", + "help.construct.axis.two_points": "יצירת ציר עזר דרך שתי נקודות, קודקודים או נקודות סקיצה. מתאים לציר שעובר בין שני מיקומים ידועים.", + "help.construct.plane.along_path": "יצירת מישור עזר ניצב למקצוע או לעקומה, במיקום לאורכם. משמש לשרטוט פרופיל לסחיפה או חתך של מסלול.", + "help.construct.plane.at_angle": "יצירת מישור עזר מוטה בזווית ממישור או מפאה, סביב ציר או מקצוע. מתאים לסקיצות משופעות, כמו תמוכה בזווית.", + "help.construct.plane.midplane": "יצירת מישור עזר באמצע בין שני מישורים או פאות שטוחות, או חוצה אותם במקום שבו הם נפגשים בזווית. משמש כמישור סימטריה לשיקוף.", + "help.construct.plane.offset": "יצירת מישור עזר מקביל למישור או לפאה שטוחה, במרחק נתון או דרך נקודה. מתאים לשרטוט בגובה קבוע מעל פאה.", + "help.construct.plane.perpendicular": "יצירת מישור עזר שמכיל קו או מקצוע וניצב למישור או לפאה. מתאים לשרטוט זקוף על המודל לאורך מקצוע.", + "help.construct.plane.tangent": "יצירת מישור עזר שמשיק לגליל, לחרוט, לכדור או לטורוס בנקודה. מתאים לשרטוט על צידו של חלק עגול.", + "help.construct.plane.three_points": "יצירת מישור עזר דרך שלוש נקודות. מתאים למישור שנקבע לפי מיקומים על המודל, כמו שלוש פינות.", + "help.construct.plane.two_edges": "יצירת מישור עזר דרך שני מקצועות ישרים או קווים הנמצאים באותו מישור. מתאים למישור שחותך חלק, למשל דרך שני מקצועות מנוגדים.", + "help.construct.point.along_path": "יצירת נקודת עזר במיקום לאורך מקצוע או עקומה. משמשת כנקודת ייחוס בחלק כלשהו של המסלול.", + "help.construct.point.center": "יצירת נקודת עזר במרכז של מקצוע עגול, מעגל או קשת בסקיצה, כדור או טורוס. משמשת כנקודת ייחוס לחורים, לצירים ולמפרקים.", + "help.construct.point.edge_plane": "יצירת נקודת עזר במקום שבו מקצוע או קו חוצים מישור או פאה שטוחה. משמשת לסימון המקום שבו מסלול חוצה מישור.", + "help.construct.point.three_planes": "יצירת נקודת עזר במפגש של שלושה מישורים או פאות שטוחות. משמשת כנקודת ייחוס לפינה, גם כשאין במודל קודקוד.", + "help.construct.point.two_edges": "יצירת נקודת עזר במפגש של שני מקצועות או קווים, או במקום שבו הם הכי קרובים. משמשת כנקודת ייחוס בפינה וירטואלית.", + "help.construct.point.vertex": "יצירת נקודת עזר על קודקוד או נקודת סקיצה. משמשת כנקודת ייחוס שנשארת גם כשהסקיצה או הגוף שסביבה מוסתרים.", + "help.contact.check": "הצגת זוגות החלקים בקבוצות מגע שחודרים זה לזה במיקומם הנוכחי. כך מוצאים התנגשויות לפני הנעת מפרקים או הפעלת חקר תנועה.", + "help.contact.create": "קיבוץ שני חלקים או יותר לקבוצת מגע, שמונעת מגופיהם לחדור זה לזה בזמן תנועת המפרקים. מתאים לחלקים שצריכים לעצור כשהם נוגעים זה בזה, כגון מנוף כנגד המעצור שלו.", + "help.contact.disable_all": "ביטול המגע, כך שמפרקים מונעים וחקר תנועה אינם נעצרים עוד במקום שבו החלקים נוגעים. כך מזיזים שוב חלקים בחופשיות או שומרים על תגובתיות של הרכבה גדולה.", + "help.contact.edit": "שינוי קבוצת מגע: החלקים שבה, שמה או מצב ההשבתה שלה, עם אפשרות גם למחוק אותה. כך משנים אילו חלקים מנועים מלחדור זה לזה.", + "help.contact.enable_all": "הכנסת כל חלקי ההרכבה לקבוצת מגע אחת, כך ששום שני חלקים אינם יכולים לחדור זה לזה בזמן תנועת המפרקים. מתאים לבדיקה מהירה של מנגנון שלם.", + "help.contact.enable_sets": "הפעלת מגע לקבוצות המגע שהוגדרו, כך שמפרקים מונעים וחקר תנועה נעצרים במקום שבו חלקיהן היו חודרים זה לזה. כך מפעילים שוב את הקבוצות לאחר ביטול המגע.", + "help.contact.list": "הצגת קבוצות המגע בתכן, החלקים בכל קבוצה והאם המגע פעיל. כך בודקים את הגדרת המגע לפני הנעת ההרכבה.", + "help.document.inspect": "הצגת סקירה של התכן: הרכיבים, הגופים, הסקיצות, הפיצ'רים והפרמטרים שבו. מתאים כדי להתמצא בתכן שלא בנית בעצמך.", + "help.document.units": "שינוי היחידות שבהן מוצגים אורכים בתכן, כמו mm או אינצ'ים. הגיאומטריה נשארת באותו גודל; רק התצוגה והמספרים הפשוטים משתנים.", + "help.edit.checkpoint": "שמירת התכן, הסקיצה הפעילה והיסטוריית הביטולים כפי שהם עכשיו. מתאים לפני ניסיון מסוכן שאולי תרצה לחזור ממנו.", + "help.edit.restore_checkpoint": "חזרה לנקודת שמירה, כולל היסטוריית הביטולים. מתאים כדי לבטל בצעד אחד את כל מה שנעשה מאז נקודת השמירה.", + "help.explode.create": "יצירת תצוגה מפוצצת שמרחיקה את החלקים ממרכז ההרכבה או מזיזה אותם לפי ההזזות שנקבעו. כך מציגים כיצד מרכיבים את החלקים, לצורך הוראות או מצגות.", "help.face.delete": "מחיקת הפאות שנבחרו, כשהפאות השכנות סוגרות את הפער, או השארת הפתח פתוח כמשטח. מתאים להסרת עיגול, חור או בליטה שמפריעים.", + "help.file.insert_mesh": "הוספת קובץ 3MF, STL, OBJ, PLY או glTF לתכן כגופי רשת משולשים ברכיב חדש. כך ממקמים מודל שהורד או נסרק לצד התכן כייחוס.", + "help.file.insert_step": "הוספת קובץ STEP, IGES, FreeCAD או Rhino לתכן כגופים מוצקים ברכיב חדש, כשחלקי הרכבה הופכים לרכיבים מקוננים. כך מייבאים מודלים מספקים או מתוכנות CAD אחרות.", + "help.file.save_mesh": "שמירת גופים כרשת משולשים מסוג STL, 3MF או OBJ, עם רמת החלקות שנבחרה לפאות מעוקלות. כך מכינים חלקים להדפסת תלת־ממד או לתוכנות שקוראות רשתות משולשים.", + "help.file.send_to_slicer": "שמירת הגופים שנבחרו כקובץ 3MF ופתיחתו בסלייסר שנבחר. כך עוברים מהמודל להדפסה בצעד אחד.", "help.font_details": "הממשק משתמש בגופנים המובנים של egui (Ubuntu Light ו־Hack, תחת Ubuntu Font Licence ותחת רישיון MIT/Bitstream Vera; Noto Emoji וגופן סמלי האימוג'י, תחת OFL ו־MIT). טקסט בסקיצות משתמש ב־Liberation Sans (SIL OFL 1.1). חבילת הגופנים האופציונלית של Crafting Apps, craft-fonts (BIZ UDPGothic, Shippori Mincho, BIZ UDMincho, Noto Sans CJK SC, Noto Sans Arabic; SIL OFL 1.1), אינה כלולה בגרסאות של SolidCraft.", "help.fonts": "גופנים", + "help.frame.member": "הצבת פרופיל, כמו צינור או פרופיל אלומיניום שחול, לאורך קווי סקיצה של מסגרת, איבר אחד לכל קו. מתאים לבניית מסגרות, מדפים ומבנים מולחמים.", + "help.inspect.accessibility": "צביעת הפאות שכלי יכול להגיע אליהן מכיוון אחד, ואלה שאינו יכול. מתאים לבדיקת חלק לעיבוד שבבי או להזרקה מאותו צד.", + "help.inspect.center_of_mass": "הצגת מרכז המסה של גופים, כל אחד לחוד וכולם יחד, בצפיפות אחידה. מתאים לבדיקת איזון ולזיהוי המקום שבו חלק יתהפך.", + "help.inspect.colored_components": "מתן צבע זמני נפרד לכל רכיב, בלי לשנות את המראות. מתאים להבחנה במבט אחד בין חלקי ההרכבה.", + "help.inspect.compare": "השוואה בין שני גופים והצגת המקומות שבהם לאחד יש חומר שאין לשני, עם נפח וסטייה. מתאים לבדיקת חלק ששונה או יובא מול המקור.", + "help.inspect.continuity": "בדיקה עד כמה חלק המפגש בין פאות לאורך המקצועות: רווחים, שברים או קפיצות בעקמומיות, וצביעת התפרים. מתאים לאיתור מקומות שבהם משטחים לא מתחברים בצורה נקייה.", + "help.inspect.curvature_comb": "שרטוט מסרקים על עקומות, מקצועות או פאות, ושיניהם מראות עד כמה הם מתעקלים. מתאים לאיתור אזורים שטוחים, שברים ועקמומיות לא אחידה.", + "help.inspect.curvature_map": "צביעת פאות לפי עוצמת העקמומיות שלהן, באדום היכן שהרדיוס קטן. מתאים לאיתור נקודות חדות ולהערכת חלקות המשטח.", + "help.inspect.draft": "צביעת פאות לפי זווית השיפוע שלהן ביחס לכיוון ההוצאה מהתבנית, וסימון אלה שהשיפוע שלהן קטן מדי. מתאים לבדיקת חלק לפני הזרקה או יציקה.", + "help.inspect.environment_map": "הצגת המודל בגימור מחזיר אור, כמו מראה. מתאים להערכת הזרימה של משטחים לפי הדרך שבה ההשתקפויות עוברות עליהם.", + "help.inspect.interference": "איתור גופים שחופפים והצגת כל חפיפה עם נפחה. מתאים לבדיקת הרכבה כדי למצוא חלקים שמתנגשים.", + "help.inspect.isocurve": "שרטוט האיזו־עקומות של פאות, הרשת שעליה בנוי המשטח. מתאים כדי לראות איך המשטח זורם והיכן הקווים מצטופפים.", + "help.inspect.measure": "מדידת אורכים, מרחקים, זוויות, שטחים ורדיוסים בין מה שנבחר, או נפח ומסה של גופים. מתאים לבדיקת מידות ומרווחים.", + "help.inspect.minimum_radius": "איתור רדיוס העקמומיות הקטן ביותר של עקומות, מקצועות או פאות, והמקום שבו הוא נמצא. מתאים לוודא שכלי או נחיר יכולים לעקוב אחרי הצורה.", + "help.inspect.section": "חיתוך התצוגה לאורך מישור וקווקו הפאות החתוכות, בלי לשנות את המודל. מתאים להצצה לתוך חלקים והרכבות.", + "help.inspect.zebra": "הצגת פסים שחורים ולבנים המשתקפים על פאות המודל. מתאים להערכת חלקות: פסים שנקטעים או נשברים במקצוע מעידים על מעבר גרוע.", + "help.joint.as_built": "חיבור שני חלקים במיקומם הנוכחי, ללא הזזת אף אחד מהם, והגדרת התנועה שעדיין מותרת להם. מתאים לחלקים שכבר מוקמו במיקומם הסופי.", + "help.joint.cam": "קישור פיקה מסתובבת לעוקב, כך שהעוקב מחליק או מתנדנד כשקו המתאר של הפיקה עובר מתחתיו. מתאים לפיקות, לאקסצנטרים ולמנגנוני הנעת שסתומים.", + "help.joint.create": "חיבור שני חלקים בראשיות המפרק שנבחרו והזזת החלק השני למקומו, כשסוג המפרק קובע את התנועה שעדיין מותרת להם. כך בונים הרכבה שחלקיה נעים כמו במנגנון האמיתי.", + "help.joint.drive": "קביעת הזווית או המרחק של מפרק והזזת החלקים איתו בתוך גבולותיו, תוך מעקב של מפרקים מקושרים ולולאות סגורות. כך בודקים שהמנגנון נע כמתוכנן.", + "help.joint.gear": "קישור שני מפרקים מסתובבים ביחס תמסורת, כך שהנעת אחד מסובבת את האחר. מתאים לגלגלי שיניים ישרים וטבעתיים, עם יחס לפי רדיוסי מעגלי החלוקה או מספרי השיניים.", + "help.joint.limits": "קביעת גבולות מינימום ומקסימום לסיבוב או להחלקה של מפרק, או הסרת הגבולות. כך שומרים ציר, מחליק או מנוף בתוך טווח התנועה האמיתי שלו.", + "help.joint.motion_link": "צימוד שני מפרקים ביחס ובהיסט, כך שהנעת אחד מזיזה את האחר. מתאים ליחס קבוע בין מפרקים שהכלים גלגל שיניים, מסרק וגלגל שיניים או בורג אינם מכסים.", + "help.joint.origin": "הנחת ראשית מפרק בעלת שם על חלק: במרכז פאה או קצה עגול, באמצע מקצוע, בפינה או בנקודה. כך מכינים את מיקומי חיבור המפרקים, וראשית שנבחרה על פאה עוקבת אחריה כשגודל החלק משתנה.", + "help.joint.rack_pinion": "קישור גלגל שיניים מסתובב למסרק מחליק, כך שהמסרק נע באורך הקשת שגלגל השיניים עובר על מעגל החלוקה. מתאים להנעות מסרק, למנגנוני היגוי ולבמות תנועה קוויות.", + "help.joint.reach": "הזזת נקודה על חלק לעבר יעד על ידי שינוי המפרקים בשרשרת שלה, בתוך גבולותיהם. כך קובעים תנוחה של מנגנון לפי המיקום הרצוי של קצהו, כגון קצה זרוע רובוט.", + "help.joint.rigid_group": "נעילת החלקים שנבחרו יחד כפי שהם, כך שינועו כיחידה אחת. מתאים לחלקים השייכים יחד ואינם נעים זה ביחס לזה.", + "help.joint.screw": "קישור סיבוב והחלקה לפי פסיעה, כך שכל סיבוב מקדם באותו מרחק. מתאים לבורגי הנעה, לברגים ולכושים בעלי תבריג.", + "help.library.apply": "החלת חומר או מראה מקובץ ספרייה מקומי על גופים, פאות או רכיבים. מתאים לשימוש חוזר בחומרים ששמרת מתכן אחר.", + "help.library.export": "שמירת החומרים והמראות של התכן בקובץ ספרייה. מתאים לשיתוף או לשימוש חוזר בתכן אחר.", + "help.library.inspect": "בדיקת קובץ ספרייה והצגת החומרים והמראות שבו, בלי לשנות את התכן. מתאים כדי לראות מה הספרייה מכילה לפני החלתה.", "help.licences": "רישיונות", "help.material.assign": "קביעת החומר שממנו עשויים הגופים שנבחרו, למשל פלדה, אלומיניום או ABS. כך המסה והמשקל תואמים את החלק האמיתי.", + "help.material.properties.validate": "בדיקת כרטיס נתונים של חומר לאיתור מספרים לא עקביים וערכים חסרים. מתאים לפני הסתמכות על הנתונים לחישוב מסה או קשיחות.", + "help.mesh.convert": "בנייה מחדש של גוף רשת, כמו STL, כגוף מוצק הניתן לעריכה עם פאות שטוחות וגליליות. מתאים לעריכת חלקים מכניים שיובאו כמו גוף שעוצב מאפס.", "help.mit": "רישיון MIT", + "help.motion.study": "קביעת ערכי מפרקים בשלבים לאורך ציר זמן והפעלתם כהנפשה. כך צופים ברצף תנועות של מנגנון ובודקים מרווחים לאורך הדרך.", + "help.motion.trace": "מעקב אחר המסלול של נקודה על חלק בזמן חקר תנועה או הנעת מפרק, ושמירתו כעקומת סקיצה תלת־ממדית לאחר האישור. כך רואים לאן מנגנון מגיע או בונים על בסיס מסלולו.", "help.notice": "NOTICE", + "help.occurrence.ground": "עיגון חלק במקומו או שחרורו מחדש, כך שהמפרקים מניעים את החלקים האחרים סביבו. מתאים למסגרת או לבסיס שעליהם נבנית שאר ההרכבה.", "help.occurrence.move": "מזיז חלק בהרכבה רק בכיוונים שהמפרקים שלו מאפשרים. בחרו פאה של החלק, ואז גררו חץ או קשת זמינים.", "help.parameters.change": "פתיחת הטבלה של ערכים בעלי שם וביטויים שקובעים את מידות התכנון. כך משנים מידה במקום אחד וכל הפיצ'רים שמשתמשים בה מתעדכנים.", + "help.parameters.export": "שמירת הפרמטרים של התכן כ־CSV או JSON. מתאים לעריכתם בגיליון אלקטרוני או להעברתם לתכן אחר.", + "help.parameters.graph": "הצגת אילו פרמטרים ופיצ'רים משתמשים באילו אחרים, ואילו לולאות או שגיאות יש ביניהם. מתאים כדי לראות על מה ישפיע שינוי בפרמטר.", + "help.parameters.import": "קריאת פרמטרים מ־CSV או JSON אל התכן, כולם או אף אחד. מתאים להבאת ערכים מגיליון אלקטרוני או מתכן אחר.", + "help.parameters.move": "הזזת פרמטר משתמש לפני פרמטר אחר או אחריו ברשימה. מתאים כדי לשמור פרמטרים קשורים יחד.", + "help.parts.fastener": "הוספת בורג, בורג עם אום, אום, דסקית, פין או מיסב סטנדרטיים בגודל שנבחר. בהנחה על חור, המחבר מתיישב בו עם מפרק, וכך אין צורך למדל מחברים בעצמכם.", + "help.parts.insert": "הוספת חלק סטנדרטי מהמשפחה, בגודל ובאורך שנבחרו. בהנחה על שפת חור או על דופנו, החלק מתיישב על החור עם מפרק קשיח.", + "help.parts.library": "פתיחת החלקים הסטנדרטיים: ברגים, ברגים עם אומים, אומים, דסקיות, בורגי קיבוע, פיני מיקום ומיסבים במידות סטנדרטיות. כך מוסיפים חלק סטנדרטי במקום למדל אותו בעצמכם.", + "help.parts.resize": "שינוי הגודל או האורך של חלק סטנדרטי שהוכנס ובנייתו מחדש, כשהמפרקים שומרים אותו במקומו. מתאים כשבורג מתגלה כארוך מדי או כשחור דורש מידה אחרת.", + "help.plastic.assign_rule": "הקצאת כלל פלסטיק לגופים, כך שפיצ'רי הפלסטיק שלהם יקבלו ממנו שיפוע ומרווח כברירת מחדל. מתאים כדי להגדיר חלק מוזרק פעם אחת לכל הפיצ'רים שלו.", + "help.plastic.boss": "הוספת בליטה לבורג על פאה: עמוד עם חור אופציונלי, שיפוע, עיגול פינה בבסיס וצלעות תמיכה. מתאים לנקודות בורג בבתים מוזרקים או מודפסים.", + "help.plastic.lip": "הוספת שפה או חריץ תואם לאורך שפת גוף חלול. מתאים כדי ששני חצאי בית יתמקמו זה על זה וייסגרו היטב.", + "help.plastic.manage_rules": "יצירה ועריכה של כללי פלסטיק: חומר, עובי דופן, רדיוסים, מרווח ושיפוע. מתאים כדי לשמור על עקביות הפיצ'רים של חלק מוזרק.", + "help.plastic.rest": "הוספת משען על פאה: כרית עגולה או מלבנית, מלאה או חלולה, עם שיפוע. משמש כתמיכה או כדוכן מרווח בבית מוזרק.", + "help.plastic.snap_fit": "הוספת וו הצמדה על פאה, עם אורכו, עוביו והבליטה הנועלת. מתאים לחיבור חלקים בלחיצה בלי ברגים.", "help.pointcloud.insert": "הוספת סריקה בפורמט PLY, XYZ או PTS כענן נקודות לצד המודל. משמש כעזר למידול על סמך נתוני מדידה.", + "help.sheet.convert": "הפיכת לוח או גוף חלול בעובי אחיד לגוף פח, עם כיפופים במקצועות שנבחרו. מתאים כדי להפוך חלק קיים לבר־פריסה.", + "help.sheet.export_bend_table": "שמירת טבלת הכיפופים של גוף פח כ־CSV או JSON: זוויות, רדיוסים ומיקומי כיפוף בפריסה. מתאים למכונת הכיפוף או לסדנה.", + "help.sheet.export_dxf": "שמירת הפריסה כ־DXF, עם קווי מתאר, קווי כיפוף והערות כיפוף אופציונליות. מתאים לחיתוך בלייזר, בפלזמה או בסילון מים.", + "help.sheet.flange": "הוספת אוגן: לוח בסיס מסקיצה, דופן לאורך קו מתאר פתוח של סקיצה, או דופן מכופפת על מקצועות הפח. מתאים לבניית חלקי פח.", + "help.sheet.flat_pattern": "יצירת הפריסה של גוף פח, עם הגודל, החיתוכים וקווי הכיפוף. מתאים לבדיקת הפח הפרוס לפני החיתוך.", + "help.sheet.fold": "כיפוף פח לאורך קו סקיצה בזווית וברדיוס שנקבעו. מתאים לקיפול פח שטוח אחרי שנוספו בו חיתוכים.", + "help.sheet.hem": "קיפול שפת פח חזרה על עצמה. מתאים לשפות בטוחות וקשיחות, בלי קצוות חדים.", + "help.sheet.manage_rules": "יצירה ועריכה של כללי פח: עובי, רדיוס כיפוף, K-factor, חריצי הקלה ורווחים. מתאים כדי להתאים לחומר ולכלים של הסדנה שלך.", + "help.sheet.path_flange": "הוספת שרשרת אוגנים לאורך שפת פח, לפי שרשרת פתוחה של קווי סקיצה. מתאים לפרופילים כמו צורת U או Z בצעד אחד.", + "help.sheet.perforate": "הוספת שדה של חורים על כל הפח הפרוס, בתבנית מוסטת או מרובעת. מתאים לפתחי אוורור, לרשתות ולפאנלים קלים יותר.", + "help.sheet.refold": "קיפול חוזר של גוף פח שנפתח. מתאים אחרי הוספת פיצ'רים לפח הפרוס.", + "help.sheet.unfold": "פתיחת כל הכיפופים של גוף פח. מתאים להוספת חורים או חיתוכים שחייבים לעבור דרך כיפוף, ואז קיפול מחדש.", + "help.sketch.arc.center_point": "משרטט קשת מעגלית סביב נקודת מרכז, מנקודת התחלה לנקודת סיום. מתאים כשמרכז הקשת ידוע, למשל רדיוס סביב חור או ציר.", + "help.sketch.arc.tangent": "משרטט קשת שמתחילה בקצה של ישר או קשת וממשיכה משיקה לו. מתאים להמשכת שרשרת בעיקול חלק במקום בפינה.", + "help.sketch.arc.three_point": "משרטט קשת מנקודת התחלה לנקודת סיום דרך נקודה שלישית. מתאים כשהקשת חייבת לעבור בנקודות נתונות ולא סביב מרכז ידוע.", + "help.sketch.arc3d": "משרטט קשת מעגלית מדויקת במרחב דרך שלוש נקודות, כשסקיצה תלת־ממדית פעילה. מתאים לעיקולים במסלולים ובמסילות הנחיה שיוצאים ממישור הסקיצה.", + "help.sketch.auto_constrain": "מוסיף את האילוצים שהסקיצה כמעט מקיימת ממילא, כמו אופקי, מקביל או מתלכד, ואז מוסיף מידות עד שהיא מוגדרת במלואה. מתאים לנעילת סקיצה גסה בצעד אחד.", + "help.sketch.auto_constrain_from_datum": "פועל כמו אילוץ אוטומטי, אך מודד את המיקומים מנקודת ייחוס שבחרתם ולא מהראשית. מתאים כשהחלק מתוכנן מנקודת ייחוס אחת, כמו פינה או חור.", + "help.sketch.blend_curve": "מחבר את הקצוות של שתי עקומות בספליין חלק המשיק לשתיהן, או גם רציף עקמומיות אם בוחרים. מתאים לגישור על פער בין עקומות בלי שבר.", + "help.sketch.break": "מפצל עקומה לחלקים במקומות שבהם עקומות אחרות חוצות אותה, או בנקודות שמניחים עליה. מתאים כשחלק מהעקומה צריך אילוץ משלו או צריך להימחק לבדו.", + "help.sketch.cancel": "יוצא מהסקיצה ומבטל כל מה ששונה בה מאז שנוצרה או נפתחה לעריכה. ביטול הפעולה מחזיר את השינויים.", + "help.sketch.centerline": "מחליף ישרים בין ישרים רגילים לקווי מרכז, שהפרופילים מתעלמים מהם. השתמשו בקו מרכז כציר לסיבוב, לשיקוף או לאילוץ סימטריה.", + "help.sketch.chamfer.distance_angle": "יוצר פאזה בפינה בין שני ישרים לפי מרחק לאורך הישר הראשון וזווית. מתאים כשהשרטוט נותן את הפאזה כאורך וזווית, כמו 2 mm × 30°.", + "help.sketch.chamfer.equal_distance": "יוצר פאזה בפינה בין שני ישרים באותו מרחק לאורך שניהם. מתאים לפאזת 45° הרגילה בקו מתאר.", + "help.sketch.chamfer.two_distance": "יוצר פאזה בפינה בין שני ישרים לפי מרחק נפרד לאורך כל ישר. מתאים לפאזות שאינן של 45°, הנתונות בשני אורכים.", + "help.sketch.circle.center": "משרטט מעגל לפי מרכזו וקוטרו. מתאים לחורים, לבליטות ולמאפיינים עגולים אחרים שמרכזם ידוע.", + "help.sketch.circle.three_point": "משרטט מעגל העובר בשלוש נקודות. מתאים כשהמעגל חייב לעבור בנקודות נתונות, למשל של גאומטריה קיימת.", + "help.sketch.circle.three_tangent": "משרטט מעגל המשיק לשלושה ישרים, מעגלים או קשתות. מתאים להכנסת מעגל לחלל שבין שלוש עקומות, כמו המעגל החסום במשולש.", + "help.sketch.circle.two_point": "משרטט מעגל משני קצות הקוטר שלו. מתאים כשנקודות הקצה של המעגל ידועות ולא מרכזו.", + "help.sketch.circle.two_tangent": "משרטט מעגל בגודל נתון המשיק לשני ישרים, מעגלים או קשתות. מתאים לעיגול ברדיוס קבוע בין שתי עקומות.", + "help.sketch.conic": "משרטט עקומת חתך חרוט בין שתי נקודות קצה, שצורתה נקבעת לפי נקודת קדקוד וערך rho. מתאים למעברים חלקים שמכוונים את מלאותם, משטוח ועד כמעט פינה חדה.", + "help.sketch.constrainer": "מחיל את האילוץ שעקומה אחת או שתיים שנבחרו כמעט מקיימות, כמו אופקי, מקביל, משיק או שווה. מתאים לסידור סקיצה שנוצרה ביד, יחס אחד בכל פעם.", + "help.sketch.constraint.coincident": "גורם לנקודה להתלכד עם נקודה אחרת, עם עקומה או עם ציר הסקיצה. מתאים לסגירת פערים בפרופיל או להנחת נקודה על ישר.", + "help.sketch.constraint.collinear": "גורם לשני ישרים לשכב על ישר אחד. מתאים ליישור קצוות מקוטעים, כמו שני צדי מגרעת.", + "help.sketch.constraint.concentric": "נותן לשני מעגלים או קשתות אותו מרכז. מתאים לטבעות, לשקעי ברגים ולצירים עם מדרגות.", + "help.sketch.constraint.curvature": "גורם לשתי עקומות שנפגשות בנקודת קצה להמשיך באותה עקמומיות (G2). מתאים כשחיבור משיק אינו חלק מספיק, למשל על משטחים גלויים.", + "help.sketch.constraint.equal": "גורם לישרים להיות באותו אורך, ולמעגלים ולקשתות באותו רדיוס. מתאים כדי שמידה אחת תניע כמה מאפיינים תואמים.", + "help.sketch.constraint.fix": "מקבע נקודות או עקומות במקומן, או משחרר אותן שוב. מתאים לגאומטריה שאסור שתזוז בגרירה או בפתרון הסקיצה.", + "help.sketch.constraint.horizontal_vertical": "גורם לישר, או לישר שבין שתי נקודות, להיות אופקי או אנכי, לפי הקרוב יותר. מתאים ליישור קו מתאר לצירי הסקיצה.", + "help.sketch.constraint.midpoint": "מציב נקודה באמצע ישר, או גורם לנקודות האמצע של שני ישרים להיפגש. מתאים למירכוז גאומטריה על קצה.", + "help.sketch.constraint.parallel": "גורם לשני ישרים להיות מקבילים. מתאים לצלעות שחייבות להישאר מקבילות כשמידות אחרות משתנות.", + "help.sketch.constraint.perpendicular": "גורם לשני ישרים להיפגש בזווית ישרה. מתאים לפינות ישרות בין ישרים שאינם על צירי הסקיצה.", + "help.sketch.constraint.polygon": "הופך שרשרת סגורה של ישרים למצולע משוכלל: צלעות שוות וכל פינה על מעגל אחד. מתאים להפיכת מצולע שצויר ביד למשוכלל ולשמירה על כך.", + "help.sketch.constraint.symmetry": "גורם לשתי נקודות או עקומות להיות תמונת מראה זו של זו סביב ישר או נקודה. מתאים לשמירה על פרופיל סימטרי כששינוי נעשה בצד אחד.", + "help.sketch.constraint.tangent": "גורם לשתי עקומות לגעת זו בזו כמשיקות, כמו ישר וקשת. מתאים לחיבורים חלקים בלי שבר.", + "help.sketch.construction": "מחליף עקומות בין גאומטריה רגילה לגאומטריית עזר, שהפרופילים מתעלמים ממנה. השתמשו בעקומות עזר לתכנון, לייחוסים ולמידות.", "help.sketch.construction_line": "יצירת ישר עזר אינסופי דרך שתי נקודות, לתכנון הסקיצה בלי לפצל את הפרופילים.", "help.sketch.construction_line.angle": "יצירת ישר עזר אינסופי בזווית מציר X של הסקיצה. יש לבחור נקודה ולהקליד זווית או לבחור כיוון.", "help.sketch.construction_line.bisector": "יש לבחור שני ישרים נחתכים בסקיצה ואז אחד משני חוצי הזווית. התוצאה עוקבת אחר עריכת ישרי המקור.", @@ -1415,12 +1571,71 @@ "help.sketch.construction_line.tangent_external": "יש לבחור מעגל או קשת מעגלית, נקודה חיצונית ואז פתרון למשיק. התצוגה המקדימה מציגה רק נקודות השקה על הקשת המצוירת.", "help.sketch.construction_line.tangent_two": "יש לבחור שני מעגלים או קשתות מעגליות ואז משיק משותף בתצוגה המקדימה. נקודות ההשקה חייבות להיות על העקומות המצוירות.", "help.sketch.construction_line.vertical": "יצירת ישר עזר אנכי אינסופי דרך נקודה. האילוץ האנכי נשמר בעריכות מאוחרות יותר.", + "help.sketch.copy": "מעתיק את עקומות הסקיצה והנקודות שנבחרו, יחד עם האילוצים והמידות שביניהן. אפשר להדביק אותן בסקיצה הזו או באחרת.", + "help.sketch.create": "מתחיל סקיצה דו־ממדית חדשה על מישור או על פאה שטוחה של המודל. כל שליפה, סיבוב וסחיפה מתחילים בסקיצה.", + "help.sketch.cut": "מעתיק את גאומטריית הסקיצה שנבחרה ומוחק אותה מהסקיצה, בצעד ביטול אחד. אפשר להדביק במקום אחר בסקיצה הזו או באחרת.", + "help.sketch.dimension": "מוסיף מידה מניעה: אורך, מרחק, זווית, רדיוס או קוטר, לפי מה שבוחרים. מקלידים ערך או שם פרמטר והסקיצה עוקבת אחריו.", + "help.sketch.edit": "פותח סקיצה קיימת לעריכה. המאפיינים שנבנו עליה מתעדכנים כשמסיימים את הסקיצה.", + "help.sketch.ellipse": "משרטט אליפסה לפי מרכזה, קצה הציר הראשי שלה ונקודה בצד הציר המשני. מתאים לפתחים אליפטיים, לזיזים ולצורות עגולות מוארכות אחרות.", "help.sketch.equation_curve": "משרטט עקומה מנוסחאות על פני טווח: x(t) ו־y(t), או y = f(x). הנוסחאות יכולות להשתמש בפרמטרים והעקומה עוקבת אחריהם: לגל סינוס, לספירלה או לאבולבנטה של שן גלגל שיניים.", + "help.sketch.extend": "מאריך ישר או קשת עד העקומה הקרובה ביותר בדרכם, או מגדיל שתי עקומות עד שייפגשו. מתאים לסגירת פערים בפרופיל אחרי גיזום או עריכה.", + "help.sketch.fillet": "מעגל את הפינה שבה נפגשים שני ישרים או קשתות בקשת ברדיוס נתון. פינות שמעגלים יחד חולקות רדיוס אחד.", + "help.sketch.finish": "יוצא מהסקיצה וחוזר למודל. הפרופילים הסגורים שלה מוכנים אז לשליפה, לסיבוב ולמאפיינים אחרים.", + "help.sketch.finish_auto_constrain": "מריץ אילוץ אוטומטי על הסקיצה ואז מסיים אותה. מתאים להשארת סקיצה מוגדרת במלואה בצעד אחד.", + "help.sketch.fit_curves_to_section": "חותך רשת משולשים או גוף מוצק במישור הסקיצה ומתאים ישרים וקשתות לחתך. מתאים לשחזור חלק שנסרק או יובא כסקיצה הניתנת לעריכה.", + "help.sketch.include_3d": "מביא קצוות, קדקודים ועקומות של סקיצות אחרות אל הסקיצה הזו כייחוסים מקושרים, ומשטח אותם למישור שלה. מתאים לבנייה על גאומטריה שאינה במישור הסקיצה.", + "help.sketch.infinite": "מחליף ישרים בין ישרים רגילים לישרי עזר אינסופיים שחוצים את כל התצוגה. מתאים לייחוסי תכנון שלעולם אינם קצרים מדי.", + "help.sketch.insert_dxf": "קריאת העקומות של שרטוט DXF לתוך סקיצה, בקנה המידה, בסיבוב ובמיקום שנקבעו. כך ממשיכים לעבוד על שרטוט דו־ממדי מתוכנה אחרת, כגון קו מתאר לחיתוך בלייזר.", + "help.sketch.insert_svg": "קריאת הנתיבים של שרטוט SVG לתוך סקיצה, בקנה המידה, בסיבוב ובמיקום שנקבעו. כך הופכים לוגו או קו מתאר וקטורי לעקומות שניתן לשחל.", "help.sketch.inspect": "פותח דוח על הסקיצה הפתוחה: כמה דרגות חופש נותרו והאם היא מאולצת במלואה. השתמשו בו כדי לגלות מה עדיין זז לפני שמסיימים את הסקיצה.", + "help.sketch.intersect": "מוסיף את העקומות או הנקודות שבהן גופים, פאות, קצוות או סקיצות אחרות חותכים את מישור הסקיצה. הן נשארות מקושרות ועוקבות אחר המודל כשהוא משתנה.", + "help.sketch.intersection_curve": "יוצר עקומות תלת־ממדיות במקום שבו שני גופים או פאות נפגשים, מקושרות אליהם. מתאים למסלולים או למסילות הנחיה שעוקבים אחר המודל.", + "help.sketch.isoparametric_curve": "יוצר עקומה תלת־ממדית לאורך כיוון U או V של פאה דרך נקודה שבוחרים, מקושרת לפאה. מתאים למסלול או לקו פיצול שעוקבים אחר המשטח.", + "help.sketch.line": "משרטט ישרים מחוברים נקודה אחר נקודה, כשרשרת פתוחה או כקו מתאר סגור. זה הכלי הבסיסי לרוב הפרופילים.", + "help.sketch.line.midpoint": "משרטט ישר מנקודת האמצע שלו אל אחד הקצוות, כשהקצה השני משוקף. מתאים לישרים הממורכזים סביב נקודה, כמו קצה סימטרי.", + "help.sketch.line3d": "משרטט ישרים מחוברים בין נקודות במרחב, כשסקיצה תלת־ממדית פעילה. מתאים למסלולים ולמסגרות שיוצאים ממישור הסקיצה.", + "help.sketch.mirror": "משקף עקומות ונקודות של סקיצה סביב ישר או ציר ומוסיף אילוצי סימטריה. מתאים לשרטוט חצי מפרופיל סימטרי והשלמתו.", + "help.sketch.move": "מזיז או מסובב עקומות ונקודות של סקיצה, או עותק שלהן. מתאים לשינוי מיקום גאומטריה תוך שמירה על צורתה ועל האילוצים.", + "help.sketch.offset": "יוצר עותק של שרשרת עקומות במרחק קבוע, בצד אחד או בשניהם. מתאים לדפנות, לקווי מתאר ולמרווחים; המרחק נשאר מידה הניתנת לעריכה.", + "help.sketch.paste": "מדביק בסקיצה הפעילה גאומטריית סקיצה שהועתקה או נגזרה. אפשר למקם אותה בנקודה או להזיז אותה בהיסט.", + "help.sketch.pattern.circular": "מעתיק גאומטריית סקיצה סביב נקודת מרכז, על פני מעגל שלם או זווית נתונה. מתאים למעגלי ברגים, לחישורים ולשיניים.", + "help.sketch.pattern.rectangular": "מעתיק גאומטריית סקיצה בשורות ובעמודות במרווחים קבועים. מתאים לרשתות של חורים, חריצים או פתחי אוורור.", + "help.sketch.point": "מציב נקודת סקיצה. משמש כייחוס למידות, לאילוצים, למרכזי חורים או למאפיינים מאוחרים יותר.", + "help.sketch.point3d": "מציב נקודה במרחב, כשסקיצה תלת־ממדית פעילה. משמש כייחוס לעקומות ולמסלולים תלת־ממדיים.", + "help.sketch.polygon.circumscribed": "משרטט מצולע משוכלל סביב מעגל, בגודל שנקבע ממרכזו ועד אמצע צלע. מתאים כשהרוחב בין הצלעות ידוע, כמו באום משושה.", + "help.sketch.polygon.edge": "משרטט מצולע משוכלל משני קצותיה של צלע אחת. מתאים כשאורך הצלע ומיקומה ידועים.", + "help.sketch.polygon.inscribed": "משרטט מצולע משוכלל בתוך מעגל, בגודל שנקבע ממרכזו ועד פינה. מתאים כשהגודל בין הפינות ידוע.", + "help.sketch.project": "מביא קצוות, פאות, קדקודים ועקומות של סקיצות אחרות אל הסקיצה הזו, מוטלים על מישורה. הם נשארים מקושרים ועוקבים אחר המודל כשהוא משתנה.", + "help.sketch.project_to_surface": "מטיל עקומות של סקיצות אחרות לאורך הנורמל של הסקיצה הזו על פאה, כעקומות תלת־ממדיות מקושרות. מתאים להנחת לוגו, קו פיצול או מסלול על משטח מעוקל.", + "help.sketch.rectangle.center": "משרטט מלבן לפי מרכזו ואחת מפינותיו. מתאים לקווי מתאר הממורכזים סביב נקודה, כמו הראשית.", + "help.sketch.rectangle.rounded": "משרטט מלבן ובו ארבע פינות מעוגלות ברדיוס אחד. מתאים ללוחות, למכסים ולפתחים עם פינות מעוגלות.", + "help.sketch.rectangle.three_point": "משרטט מלבן לפי צלע אחת ונקודה בצד הנגדי. מתאים למלבנים שנטויים ביחס לצירי הסקיצה.", + "help.sketch.rectangle.two_point": "משרטט מלבן משתי פינות נגדיות. מתאים לקווי המתאר של לוחות, גושים וכיסים.", + "help.sketch.scale": "משנה קנה מידה של עקומות ונקודות בסקיצה בפקטור סביב נקודת בסיס. מתאים לשינוי גודל של חלק מהסקיצה בלי לשרטט אותו מחדש.", + "help.sketch.slot.arc_center": "משרטט חריץ מעוקל ברוחב נתון לאורך קשת שנקבעת לפי מרכזה, תחילתה וסופה. מתאים לחריצי כוונון סביב ציר.", + "help.sketch.slot.arc_three_point": "משרטט חריץ מעוקל ברוחב נתון לאורך קשת העוברת בשלוש נקודות. מתאים כשמסלול החריץ חייב לעבור בנקודות נתונות.", + "help.sketch.slot.center_point": "משרטט חריץ ישר ברוחב נתון ממרכזו ועד מרכז קשת הקצה האחת. מתאים לחריצים הממורכזים סביב נקודה.", + "help.sketch.slot.center_to_center": "משרטט חריץ ישר ברוחב נתון בין מרכזי קשתות שני קצותיו. מתאים לחריצים שמידותיהם ממרכז למרכז, כמו חריצי כוונון לברגים.", + "help.sketch.slot.overall": "משרטט חריץ ישר ברוחב נתון מקצהו החיצוני האחד אל השני. מתאים כשהאורך הכולל של החריץ הוא העיקר.", + "help.sketch.spline.control_point": "משרטט ספליין מעוקב שצורתו נקבעת על ידי נקודות בקרה שהוא אינו עובר בהן. מתאים לעקומות חלקות וזורמות שמעצבים בהזזת הנקודות.", + "help.sketch.spline.control_point_5": "משרטט ספליין ממעלה 5 שצורתו נקבעת על ידי נקודות בקרה. מתאים כשעקומות צריכות להתחבר בעקמומיות חלקה יותר, למשל במשטחים מעוצבים גלויים.", + "help.sketch.spline.fit_point": "משרטט ספליין חלק שעובר בכל נקודה שמניחים, פתוח או סגור. מתאים למעקב אחר צורה דרך נקודות ידועות.", + "help.sketch.spline3d": "משרטט עקומה חלקה במרחב דרך נקודות, כשסקיצה תלת־ממדית פעילה. מתאים למסלולי סחיפה ולמסלולי כבלים שיוצאים ממישור הסקיצה.", + "help.sketch.spun_profile": "יוצר במישור הסקיצה את קו המתאר שגוף סוחף כשהוא מסתובב סביב ציר. מתאים לקבלת צללית של חלק מחרטה לצורך סיבוב או שרטוט.", + "help.sketch.text": "כותב טקסט בסקיצה בגופן, בגובה ובזווית שבוחרים, ואפשר שהמילים יעקבו אחר פרמטרים. שלפו אותו או השתמשו בהבלטה לתוויות ולכיתובים.", + "help.sketch.transform": "משנה קנה מידה, מסובב ומזיז גאומטריית סקיצה סביב נקודת מרכז בצעד אחד. מתאים לשינוי מיקום וגודל של בחירה בבת אחת.", + "help.sketch.trim": "מסיר את קטע העקומה שבין החיתוכים הקרובים ביותר סביב המקום שלחצתם עליו. מתאים לניקוי ישרים חופפים עד לפרופיל סגור.", "help.solid.align": "הזזת הגופים שנבחרו כך שנקודה, פאה או מישור שלהם יפגשו נקודה, פאה או מישור במקום אחר. מתאים להצמדת חלק לחלק אחר בלי למדוד את המרחק.", + "help.solid.boundary_fill": "יצירת מוצקים מהאזורים הסגורים בין גופי כלי חופפים, תוך מילוי האזורים שנבחרו בלבד. כך בונים צורה מהחלל שכמה גופים תוחמים יחד.", + "help.solid.bounding_solid": "יצירת תיבה שחוסמת את הגופים שנבחרו, עם אפשרות לתוספת שוליים. כך רואים את המידות הכוללות של חלק או יוצרים את חומר הגלם שממנו הוא יעובד.", + "help.solid.box": "יצירת תיבה לפי אורך, רוחב וגובה, החל מנקודת פינה או ממורכזת סביב נקודה. מתאים ליצירה מהירה של גוש, מציין מקום או גוף לחיתוך מאפיינים בתוכו.", "help.solid.chamfer": "חיתוך המקצועות שנבחרו בשיפוע ישר במרחק שנקבע. כך שוברים מקצועות חדים או מקלים על הכנסה של פין או בורג.", + "help.solid.coil": "יצירת גוף סלילי, כגון קפיץ, לפי קוטר, מספר כריכות, גובה או פסיעה וחתך עגול או מרובע. מתאים לקפיצים, לסלילים ולבליטות סליליות.", "help.solid.combine": "איחוד גופים, חיסורם מגוף יעד או השארת הנפח המשותף להם בלבד, עם אפשרות לשמור את גופי הכלי. מתאים למיזוג חלקים, לחיתוך צורה אחת בעזרת אחרת או למציאת החפיפה בין שני גופים.", + "help.solid.cone": "יצירת חרוט ממעגל בסיס וגובה, או חרוט קטום כשנקבע גם רדיוס בחלק העליון. מתאים לצורות מתחדדות, למשפכים, לשיקועים חרוטיים ולחודים.", + "help.solid.cylinder": "יצירת גליל לפי רדיוס או קוטר וגובה, הניצב בנקודה לאורך ציר. מתאים לצירים, לפינים, לבליטות גליליות ולגולמים עגולים.", "help.solid.draft": "הטיית הפאות שנבחרו בזווית שנקבעה, הנמדדת ממישור ניטרלי. מתאים לחלקים יצוקים או מוזרקים, כדי שישתחררו מהתבנית.", + "help.solid.emboss": "הבלטת פרופילי סקיצה, כגון טקסט או לוגו, מתוך פאה או השקעתם בתוכה. מתאים לאותיות, לתוויות ולסימונים על פאות שטוחות או עגולות.", "help.solid.extrude": "דחיפת פרופיל של סקיצה לתלת־ממד לאורך מרחק, להוספה או להסרה של חומר. כך הופכים קו מתאר שטוח לגוש, לבליטה או לכיס.", "help.solid.fillet": "עיגול המקצועות שנבחרו במעבר מעגלי חלק ברדיוס שנקבע. כך מרככים מקצועות חדים לטובת חוזק, נוחות או מראה.", "help.solid.hole": "קידוח חור עגול בקוטר שנקבע בנקודה שנבחרה על פאה. מתאים לחורי מעבר לברגים, לפינים ולפתחים עגולים פשוטים אחרים.", @@ -1429,20 +1644,60 @@ "help.solid.move": "הזזה או סיבוב של הגופים שנבחרו, או של עותק שלהם, במרחק ובזווית שנקבעו. מתאים לשינוי מיקום של גוף או ליצירת גוף נוסף במקום אחר.", "help.solid.offset_face": "דחיפת הפאות שנבחרו פנימה או החוצה במרחק שנקבע, והפאות שסביבן מתאימות את עצמן. כך מעבים דופן, מעמיקים כיס או מגביהים בליטה בלי לערוך את הסקיצה.", "help.solid.pattern.circular": "שכפול הפריטים שנבחרו סביב ציר, לפי המספר והזווית הכוללת שנקבעו. מתאים למעגלי ברגים, לחישורים, לצלעות קירור ולשיניים.", + "help.solid.pattern.path": "העתקת פיצ'רים או גופים לאורך מסלול סקיצה, לפי כמות ומרווח. מתאים לחורים לאורך מקצוע מעוקל או לחלקים לאורך מסילה.", "help.solid.pattern.rectangular": "שכפול הפריטים שנבחרו בכיוון אחד או בשניים, לפי המספר והמרווח שנקבעו. מתאים לשורות ולרשתות של חורים, צלעות, פינים או רגליות.", + "help.solid.pipe": "יצירת צינור עגול לאורך נתיב של עקומות סקיצה, מלא או חלול עם עובי דופן. מתאים לצינורות, לחוטים, לכבלים ולמסילות.", "help.solid.press_pull": "הוספה או הסרה של חומר בגרירת פאה, או עיגול מקצועות בגרירתם. מתאים לשינויים מהירים ישירות על המודל.", "help.solid.remove": "הוצאת הגופים שנבחרו מהתכנון מנקודה זו בציר הזמן והלאה; השלבים הקודמים שומרים אותם. מתאים להסרת גוף עזר, כמו כלי חיתוך שכבר אין בו צורך.", "help.solid.replace_face": "העברת הפאות השטוחות שנבחרו אל מישור או אל פאה מקבילה, והגוף מתארך או מתקצר כדי להגיע אליהם. מתאים ליישור פאה בקו אחד עם חלק אחר או עם מישור עזר.", "help.solid.revolve": "סיבוב פרופיל של סקיצה סביב ציר ליצירת גוף מוצק עגול. מתאים לגלים, לכפתורים, לקערות ולכל חלק שעגול סביב קו מרכז.", + "help.solid.rib": "הוספת צלע דקה לאורך עקומת סקיצה פתוחה, הנמשכת עד לפאות הבאות של הגוף. כך מחזקים דפנות ופינות, למשל בחלקים יצוקים או מודפסים.", "help.solid.scale": "הגדלה או הקטנה של הגופים שנבחרו לפי מקדם, זהה בכל הכיוונים או שונה לכל ציר. מתאים לשינוי גודל של צורה מיובאת או להתחשבות בהתכווצות.", "help.solid.shell": "חלילת גוף מוצק תוך השארת דפנות בעובי שנקבע, עם אפשרות לפתוח את הפאות שנבחרו. מתאים לקופסאות, למכסים ולחלקים אחרים בעלי דופן דקה.", + "help.solid.sphere": "יצירת כדור לפי רדיוס או קוטר סביב נקודת מרכז. מתאים לכדורים, לכפתורי אחיזה ולקצוות מעוגלים.", "help.solid.split_body": "פיצול גוף לגופים נפרדים בעזרת מישור, פאה של גוף אחר או קו בסקיצה. מתאים ליצירת חלקים שמודפסים, מעובדים או נצבעים כל אחד בנפרד.", "help.solid.split_face": "פיצול הפאות שנבחרו לאורך מישור, פאה של גוף אחר או עקומות של סקיצה, בלי לחתוך את הגוף. כך נותנים לחלק מפאה שיפוע, צבע או היסט משלו.", "help.solid.sweep": "גרירת פרופיל של סקיצה לאורך מסלול ליצירת גוף מוצק. מתאים לצינורות, למסילות, לידיות ולכל חלק בעל חתך קבוע שעוקב אחרי עקומה.", + "help.solid.thread": "הוספת תבריג מטרי לפי ISO לפאה גלילית, כתבריג סמלי או כחיתוך בגוף. מתאים לברגים, לחורים בעלי תבריג ולחלקי הברגה מודפסים.", + "help.solid.torus": "יצירת טבעת לפי רדיוס ראשי סביב הציר שלה ורדיוס משני לחתך העגול. מתאים לטבעות אטימה, לידיות ולבליטות טבעתיות עגולות.", + "help.solid.web": "הוספת דופן דקה לאורך כל עקומת סקיצה פתוחה, כשכל אחת נמשכת עד לפאות הבאות של הגוף. מתאים לרשת דפנות תמיכה בתוך מארזים ומגשים.", + "help.surface.blend": "יצירת משטח שמגשר בין מקצועות של שני גופים, משיק או רציף בעקמומיות לשניהם. מתאים לחיבור חלק בין שני משטחים מעל רווח.", + "help.surface.boundary": "יצירת משטח שממלא לולאה של שניים עד ארבעה עקומות סקיצה, או רשת של עקומות. מתאים ללוחות בצורה חופשית שמוגדרים לפי הקצוות שלהם.", + "help.surface.extend": "הארכת משטח מעבר לקצוותיו, במרחק נתון או עד מישור או פאה. מתאים כדי שמשטחים יחפפו לפני גיזום או תפירה.", + "help.surface.extrude": "דחיפת עקומות או פרופילים של סקיצה לאורך מרחק למשטח, דפנות בלבד בלי מכסים. מתאים לדפנות דקות ולכלי חיתוך או פיצול.", + "help.surface.fair": "החלקת פנים הפאות החופשיות כשהקצוות שלהן נשארים במקומם. מתאים ליישור בליטות והשתקפויות גליות במשטח.", + "help.surface.loft": "יצירת משטח שעובר דרך שני חתכים או יותר לפי הסדר, בלי מכסים. מתאים לקליפות ולמעברים בין צורות שונות.", + "help.surface.match": "בנייה מחדש של קצה משטח כך שייפגש עם קצה של גוף אחר, בהתאמת משיק או עקמומיות. מתאים לסגירה חלקה של תפר בין שני משטחים.", + "help.surface.offset": "יצירת עותק של פאות במרחק לאורך הנורמלים שלהן, כגוף משטח חדש. מתאים לקליפות, למרווחים או לצדו השני של דופן.", + "help.surface.patch": "מילוי אזור סקיצה או לולאה סגורה של מקצועות בפאה, שטוחה או מעוקלת בעדינות. מתאים לסגירת חורים בגוף משטח.", + "help.surface.reverse_normal": "היפוך פאות של גוף משטח כך שצדן החיצוני יפנה לכיוון ההפוך. מתאים לפני עיבוי או תפירה כשהפאות פונות פנימה.", + "help.surface.revolve": "סיבוב עקומות או פרופילים של סקיצה סביב ציר למשטח, בלי מכסים. מתאים לקליפות עגולות כמו אהילים, קערות וזרבובים.", + "help.surface.ruled": "יצירת רצועה של קווים ישרים היוצאים משרשרת מקצועות, בכיוון הנורמל של הפאה, המשיק או בזווית. מתאים לדפנות שיפוע, לאזורי מעבר ולמשטחי חלוקה.", + "help.surface.stitch": "תפירת גופי משטח זה לזה לאורך המקצועות המשותפים, לגוף מוצק אם הם סוגרים נפח. מתאים להפיכת קבוצת משטחים לגוף אחד.", + "help.surface.sweep": "הזזת עקומות או פרופילים של סקיצה לאורך מסלול למשטח, בלי מכסים. מתאים למרזבים, לפסי קישוט ולצורות דקות אחרות שעוקבות אחרי מסלול.", + "help.surface.thicken": "מתן עובי לגוף משטח והפיכתו לגוף מוצק. מתאים ליצירת חלקים להדפסה או לעיבוד שבבי ממשטחים.", + "help.surface.trim": "חיתוך משטח בעזרת מישור, משטח אחר או עקומות סקיצה, ושמירת הצד שנבחר. מתאים לגיזום משטחים לצורה לפני תפירה.", + "help.surface.unstitch": "הפרדת פאות של גוף לגופי משטח נפרדים; גוף מוצק מתפרק למשטחים. מתאים לעיבוד מחדש של פאות בודדות בגוף.", + "help.surface.untrim": "הסרת חורים מפאה, או החזרת הגבול החיצוני שלה לגבולות הטבעיים של המשטח. מתאים לשחזור המשטח המלא אחרי גיזום.", "help.third_party": "רישיונות צד שלישי", "help.timeline.compute_all": "בנייה מחדש של כל הפיצ'רים בציר הזמן מההתחלה. מתאים לרענון המודל ולבדיקה אילו פיצ'רים נכשלים.", "help.timeline.delete": "מחיקת הפיצ'רים שנבחרו מציר הזמן; מחיקת סקיצה מוחקת גם את הפיצ'רים שנבנו ממנה. מתאים להסרת שלב שכבר אין בו צורך.", + "help.timeline.dependents": "הצגת הפיצ'רים שיימחקו יחד עם פיצ'ר, ואלה שייכשלו בלעדיו. מתאים לפני מחיקה או שינוי של פיצ'ר.", "help.timeline.play": "הפעלה מחדש של הפיצ'רים שנותרו, אחד בכל פעם, עם השהיה של חצי שנייה לאחר כל בנייה מחדש. להפסקה יש להפעיל שוב את הפקודה או ללחוץ על Escape; מעבר לסוף מתבצע מיד.", + "help.view.apply": "סיבוב התצוגה לכיוון סטנדרטי כמו חזית, מבט עליון או איזומטרי, או לתצוגה שמורה בשם. מתאים להתבוננות במודל מזווית קבועה.", + "help.view.delete": "מחיקת תצוגה שמורה. המודל אינו משתנה.", + "help.view.fit": "זום והזזה כך שכל המודל, או הבחירה, ימלאו את התצוגה. מתאים כשהמודל יצא מהטווח הנראה.", + "help.view.grid": "הצגה או הסתרה של הרשת בחלון התצוגה. משמשת כמדריך לגודל ולכיוון בזמן המידול.", + "help.view.ground_shadow": "הצגה או הסתרה של צל המודל על הקרקע. מתאים כדי להעריך גובה ומיקום ביתר קלות.", + "help.view.home": "החזרת התצוגה לכיוון הבית והתאמת המודל לחלון. מתאים לחזרה מהירה לתצוגה מוכרת.", + "help.view.list": "הצגת רשימת התצוגות השמורות בתכן. מתאים כדי למצוא תצוגה ששמרת קודם.", + "help.view.navigate": "סיבוב, הזזה או זום של התצוגה בצעדים, כמו רבע סיבוב שמאלה. מתאים להזזה מדויקת של המצלמה במקום גרירה.", + "help.view.navigation_bar": "הצגה או הסתרה של סרגל הניווט בתחתית חלון התצוגה. אפשר להסתיר אותו לשטח נוסף, או להציג אותו כדי להגיע אל כלי התצוגה בעכבר.", + "help.view.rename": "מתן שם חדש לתצוגה שמורה. התצוגה עצמה נשארת כפי שהיא.", + "help.view.save": "שמירת מיקום המצלמה הנוכחי כתצוגה בשם בתכן. מתאים לחזרה לאותה זווית בהמשך, למשל לצילומי מסך.", + "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": "אישור מחיקה", @@ -1656,6 +1911,7 @@ "nav.hfss.help": "Alt וגרירה שמאלית מזיזים, Shift+Alt וגרירה שמאלית מבצעים זום, Ctrl וגרירה שמאלית משטח ריק מסובבים", "nav.inventor.help": "גרירה אמצעית מזיזה, Shift וגרירה אמצעית מסובבים, Ctrl וגרירה אמצעית מבצעים זום, גרירה ימנית מסובבת", "nav.maya.help": "Alt וגרירה שמאלית מסובבים, Alt וגרירה אמצעית מזיזים, Alt וגרירה ימנית מבצעים זום; גם גרירה אמצעית מזיזה", + "nav.nx.help": "גרירה בלחצן האמצעי מסובבת, Shift+האמצעי מזיז את התצוגה (ב-NX: אמצעי + ימני), Ctrl+האמצעי מקרב ומרחיק (ב-NX: אמצעי + שמאלי); גרירה בלחצן הימני לא מזיזה את המצלמה", "nav.openscad.help": "גרירה שמאלית על שטח ריק מסובבת, Shift וגרירה שמאלית בוחרים במלבן, גרירת גאומטריית סקיצה עורכת אותה; גרירה ימנית או אמצעית מזיזה, Shift וגרירה ימנית או אמצעית מבצעים זום", "nav.perspective.sketch": "עריכת סקיצה משתמשת בתצוגה אורתוגרפית. יש לסיים את הסקיצה כדי לעבור לפרספקטיבה.", "nav.right_drag_orbits.help": "כבוי: גרירה בכפתור הימני לא מזיזה את המצלמה, והכפתור הימני רק פותח את תפריט ההקשר. בחירת מיפוי העכבר SolidWorks מכבה זאת.", @@ -1877,6 +2133,8 @@ "prefs.ctrl_cmd": "Ctrl / Cmd", "prefs.curved_surfaces": "משטחים מעוקלים", "prefs.curves": "משטחים מעוקלים", + "prefs.dark_parts_shading": "שמירה על צורת חלקים כהים", + "prefs.dark_parts_shading_help": "הצללת התצוגה באור ליניארי עם תאורת שמיים רכה, כך שפאות כהות שומרות על מעבר גוונים במקום להפוך לשחורות", "prefs.dead_zone_note": "הפס המקווקו הוא האזור המת: תנועה בתוכו לא נלקחת בחשבון.", "prefs.default": "ברירת מחדל", "prefs.design": "ברירות מחדל לתכן", @@ -1891,6 +2149,8 @@ "prefs.edges": "מקצועות והצללה", "prefs.extend": "הרחבה", "prefs.extra_navigation": "Ctrl עם גרירה שמאלית במקום ריק מסובב את המבט; החזקת Space מזיזה אותו.", + "prefs.extrude_view_isometric": "סיבוב התצוגה לאיזומטרית בפתיחת שליפה", + "prefs.extrude_view_return": "חזרה לתצוגה הקודמת בסגירת שליפה", "prefs.follows_os": "לפי מערכת ההפעלה", "prefs.font": "גופן", "prefs.font_fallback": "תווים שחסרים בגופן הנבחר מוצגים בגופן המובנה, כך שאף תווית לא מופיעה כריבוע ריק.", @@ -1906,6 +2166,7 @@ "prefs.graphics_card_auto": "אוטומטי", "prefs.graphics_card_missing": "הכרטיס הגרפי השמור אינו זמין; נעשה שימוש באפשרות אוטומטי.", "prefs.graphics_card_restart": "ייכנס לתוקף אחרי הפעלה מחדש של SolidCraft. WGPU_ADAPTER_NAME גובר על בחירה זו.", + "prefs.graphics_card_unused": "כרטיס המסך השמור לא שימש לחלון זה; הכרטיס שבשימוש מוצג בעזרה › אודות ובשורת המצב. נסו אוטומטי, או הגדירו את WGPU_ADAPTER_NAME לחלק ייחודי משם הכרטיס.", "prefs.graphics_note": "איזון בין מהירות לחלקות. כל בחירה מציגה את השפעתה על דוגמה.", "prefs.graphics_presets_note": "הגדרות קבועות מראש קובעות יחד את הבחירות שלמטה.", "prefs.hide_constraint_glyphs_zoomed_out": "הסתרת סמלי אילוצים בהקטנה", @@ -1920,6 +2181,8 @@ "prefs.history_sketches_note": "כשהסקיצות מוגדרות ל„מתחת לפיצ'ר שלהן”, ציר הזמן משמאל מציג כל סקיצה רמה אחת פנימה, מתחת לפיצ'ר הראשון שנבנה ממנה, בכל פעם שהוא רחב מספיק להצגת שמות. ציר הזמן בתחתית שומר תמיד על סדר היצירה.", "prefs.history_sketches_under_feature": "מתחת לפיצ'ר שלהן", "prefs.hold_stop_snapping": "החזקה לעצירת ההצמדה", + "prefs.home_fit_size": "גודל בתצוגת בית ובהתאמה", + "prefs.home_fit_size_note": "כמה מהתצוגה המודל ממלא כשתצוגת בית, התאמה, עיצוב שנפתח או שינוי תצוגה בניווט ממסגרים אותו, בין אם ההגדרה שלמטה פעילה ובין אם לא. 100 %: ממלא את התצוגה שבין הלוחות, המצפן וסרגל הניווט.", "prefs.icons_only": "סמלים בלבד", "prefs.imported_file": "קובץ מיובא", "prefs.in_browser": "בעץ התכן", @@ -1928,13 +2191,20 @@ "prefs.interface_language": "שפת הממשק", "prefs.interface_note": "איך SolidCraft נראה והיכן חלקיו ממוקמים.", "prefs.interval_minutes": "{n} דק׳", + "prefs.invert_navigator_arrows": "היפוך חצי סיבוב קוביית התצוגה", + "prefs.invert_navigator_arrows_note": "החצים סביב הקובייה מחוויים את המצלמה לכיוון שהחץ מצביע, כמו ב-Fusion ו-Inventor: החץ שמשמאל מצביע ימימה ומניע את המצלמה ימימה. כבוי: החץ מסובב את הקובייה אל הצד שבו הוא יושב, כך שחץ שמאל מניע את המצלמה שמאלה. חצי הגלילה המעוגלים אינם מושפעים.", + "prefs.keep_work_on_tab_switch": "השארת תיבות דו־שיח וכלים פתוחים במעבר בין עיצובים", + "prefs.keep_work_on_tab_switch_note": "כל לשונית עיצוב שומרת את תיבת הדו־שיח או הכלי הפתוחים בה, עם מה שבחרת והקלדת בהם, ואת לשונית סרגל הכלים שלה. הם מחכים לך כשתחזור לעיצוב. כבוי: מעבר בין עיצובים מבטל אותם ומציג את הלשונית מודל.", "prefs.labels": "תוויות", + "prefs.language_system": "מערכת", "prefs.layout": "פריסה", "prefs.layout_note": "הפריסה הדחוסה מצמידה חלוניות לשולי החלון. שקיפות עץ התכן זמינה בפריסה הצפה. את צד העוזר אפשר לשנות בעמוד העוזר.", "prefs.left": "שמאל", "prefs.left_click": "לחיצה שמאלית", "prefs.line_drag_draws_one": "לחיצה וגרירה משרטטות קו אחד", "prefs.line_drag_draws_one_note": "בכלי הקו, לחיצה, גרירה ושחרור משרטטים קו אחד מנקודת הלחיצה עד נקודת השחרור, והוא מסתיים שם. לחיצות עדיין משרטטות שרשרת. כבוי: גרירה מציבה נקודה היכן שמשחררים, כמו לחיצה.", + "prefs.line_hover_tangent_arc": "השהיה על הנקודה האחרונה של קו עוברת לקשת משיקה", + "prefs.line_hover_tangent_arc_note": "בכלי הקו, אחרי ציור מקטע, התרחקו מהנקודה האחרונה של השרשרת, חזרו אליה והחזיקו את הסמן עליה ללא תנועה: הלחיצה הבאה תסיים קשת משיקה, ואותה מחווה מחזירה לקווים. כבוי: השהיה שם אינה עושה דבר.", "prefs.linked_designs": "תכנים מקושרים", "prefs.live_input": "קלט חי", "prefs.live_preview": "תצוגה מקדימה חיה", @@ -1958,6 +2228,8 @@ "prefs.mouse_mapping": "מיפוי העכבר", "prefs.navigation": "ניווט", "prefs.navigation_note": "איך העכבר, משטח המגע והעכבר התלת־ממדי מזיזים את המבט.", + "prefs.navigator_fit": "התאמת המודל לאחר שינוי תצוגה בניווט התצוגה", + "prefs.navigator_fit_note": "לחיצה על פאה, פינה או חץ בקובייה, או על ציר בגיזמו, גם מביטה אל מרכז המודל מהמרחק האחד שמכיל אותו מכל צד (הכדור החוסם שלו). תצוגת בית משתמשת באותו זום ובאותו מרכז כל עוד זה פעיל, כך שתצוגות שנבחרות אחריה רק מסתובבות. כבוי: התצוגה מסתובבת במקומה ושומרת על היעד ועל הזום.", "prefs.network_host_remove": "הסרה", "prefs.network_locations": "התרת מיקומי רשת בתכנים", "prefs.network_locations_note": "כבוי: קבצים מקושרים של תכן במחשב אחר (\\\\server\\share) נפתחים רק מהשרתים הרשומים כאן, או אחרי שמתירים שרת כשנשאלים.", @@ -2006,6 +2278,8 @@ "prefs.render_previous_geometry_budget": "שימוש בתקציב הגאומטריה הקודם לתצוגה מקדימה של רינדור", "prefs.render_previous_geometry_budget_help": "שחזור מגבלת הגאומטריה הקבועה הקודמת (512 MiB במחשב שולחני של 64 סיביות, 128 MiB בפלטפורמות אחרות) במקום התקציב המבוסס על ההתקן.", "prefs.reports_saved": "{n} שמורים", + "prefs.ribbon_click_ends_tool": "לחיצה על הלחצן של הכלי הפעיל מסיימת אותו", + "prefs.ribbon_click_ends_tool_note": "לחיצה על לחצן הסרט המודגש של כלי השרטוט שבשימוש מסיימת את הכלי, כך שלחיצות בתצוגה בוחרות שוב. כבוי: הלחיצה מפעילה את הכלי מחדש.", "prefs.ribbon_reveal": "הצגת תוצאות חיפוש בסרט", "prefs.ribbon_reveal_note": "מעבר עם הסמן מעל תוצאה בחיפוש פקודות מראה היכן הפקודה נמצאת בסרט: הלשונית, הכפתור או השורה שלה בתפריט הנפתח של לוח. פקודה שהופעלה מהחיפוש מהבהבת שם, ופקודה פעילה מתפריט נפתח מסמנת את הלוח שלה.", "prefs.right_click": "לחיצה ימנית", @@ -2035,6 +2309,8 @@ "prefs.sketch_exit_view.previous": "חזרה לתצוגה הקודמת", "prefs.sketch_exit_view.unless_rotated": "חזרה אלא אם התצוגה סובבה", "prefs.sketch_exit_view_note": "לאן התצוגה עוברת אחרי סיום סקיצה, ביטול או Esc כפול.", + "prefs.sketch_face_activates_part": "שרטוט על פאה של חלק הופך את החלק לפעיל", + "prefs.sketch_face_activates_part_note": "שרטוט שמתחיל על פאה של גוף ברכיב אחר מפעיל קודם את הרכיב הזה, כך שהשרטוט שייך לו ונשאר עם החלק בסייר. החלק נשאר פעיל אחר כך. כבוי: השרטוט שייך לרכיב הפעיל.", "prefs.sketch_open_look_at": "הפניית התצוגה אל הסקיצה בפתיחתה", "prefs.sketch_open_look_at_note": "כבוי: יצירה או עריכה של סקיצה משאירות את התצוגה כפי שהיא. «מבט אל» מפנה את התצוגה אל הסקיצה כשרוצים.", "prefs.sketch_recent_snap": "הצמדה אוטומטית למרכזים ולנקודות אמצע אחרונים", @@ -2048,6 +2324,8 @@ "prefs.sketch_tab_always_note": "לשונית הסקיצה נשארת ברצועה, מיד אחרי מודל, גם כשאין סקיצה פתוחה. כלי השרטוט בה מתחילים אז סקיצה על המישור או הפאה שנבחרו, או על המישור שתבחרו בהמשך, והכלי מוכן בה.", "prefs.sketch_tracking_guides": "קווי עזר ליישור מרכזים ונקודות אמצע", "prefs.sketch_tracking_guides_note": "יש לרחף עם הסמן מעל מרכז מעגל או נקודת אמצע של צלע ישרה, ואז להזיז אותו אופקית או אנכית כדי להציג קו עזר מקווקו זמני.", + "prefs.sketch_tracking_points": "קווי עזר ליישור מנקודות קצה ומפינות בזמן שרטוט", + "prefs.sketch_tracking_points_note": "כשכלי שרטוט ממתין לנקודה, ריחוף מעל קצה של קו או קשת, פינה של מלבן או נקודה חופשית מציג את אותו קו עזר מקווקו זמני כמו במרכז או בנקודת אמצע. כבוי: מרכזים ונקודות אמצע בלבד.", "prefs.sketching": "שרטוט סקיצות", "prefs.snap_drawing_note": "בזמן ציור או גרירה של נקודת סקיצה.", "prefs.source": "מקור", @@ -2064,6 +2342,24 @@ "prefs.status_error": "שגיאה", "prefs.status_selected": "נבחר", "prefs.status_warning": "אזהרה", + "prefs.structure.appearance": "מראה", + "prefs.structure.appearance_note": "צבעים, טקסט ונגישות.", + "prefs.structure.in_profile": "כלול בפרופיל", + "prefs.structure.layout": "פריסה", + "prefs.structure.layout_note": "מיקום חלקי החלון.", + "prefs.structure.look": "מראה", + "prefs.structure.measure_pointer": "מדידה ומצביע", + "prefs.structure.mouse3d": "עכבר תלת־ממדי", + "prefs.structure.mouse3d_note": "SpaceMouse ו־SpaceNavigator.", + "prefs.structure.navigation_note": "עכבר ומשטח מגע. לעכבר התלת־ממדי יש עמוד משלו.", + "prefs.structure.profile": "פרופיל", + "prefs.structure.profile_edited": "השתנה מאז השמירה", + "prefs.structure.profile_hover": "נשמר בפרופילי הגדרות", + "prefs.structure.scale_percent": "{percent}%", + "prefs.structure.sketch": "סקיצה", + "prefs.structure.tools": "כלים", + "prefs.structure.tools_note": "אופן הפעולה של כלי המידול.", + "prefs.structure.work": "עבודה", "prefs.supersampling_brief": "מצייר את המבט גדול יותר ומקטין אותו: חד מאוד, וכבד יותר.", "prefs.tab_accent_note": "צבע ההדגשה עוקב אחר הלשונית הפעילה", "prefs.theme_file": "קובץ ערכת נושא", @@ -2075,6 +2371,16 @@ "prefs.theme_follow_off": "כבוי", "prefs.theme_follow_stop": "הפסקת המעקב", "prefs.theme_import_hint": "לחצו כדי לבחור קובץ ערכת נושא, שחררו קובץ כאן, או הקישו Ctrl+V ‏(Cmd+V ב-Mac) אחרי שהעתקתם ערכת נושא", + "prefs.theme_option_opacity": "אטימות", + "prefs.theme_option_opacity_help": "כוונון אטימות הלוחות הצפים. רמת אטימות מינימלית שומרת על קריאות הטקסט על רקע צבעי חלון התצוגה.", + "prefs.theme_option_tab_colours": "צבעי כרטיסיות", + "prefs.theme_option_tab_colours_help": "השתמש בצבעי הלוח ואזור התצוגה של ערכת נושא זו עבור כל כרטיסייה ברצועת הכלים.", + "prefs.theme_option_tab_fade_ms": "מעבר בין כרטיסיות", + "prefs.theme_option_tab_fade_ms_help": "שנה בהדרגה את צבעי הלוח ואזור התצוגה בעת מעבר בין כרטיסיות. ערך אפס מבצע את המעבר מיד.", + "prefs.theme_options_badge": "אפשרויות", + "prefs.theme_options_more": "עוד · {count}", + "prefs.theme_options_reset": "איפוס", + "prefs.theme_options_unavailable": "לא זמין במכשיר זה", "prefs.theme_paste": "הדבקת ערכת נושא", "prefs.theme_paste_none": "בלוח אין ערכת נושא של SolidCraft", "prefs.timeline_half_hour": "−30 דק׳", @@ -2109,6 +2415,8 @@ "prefs.viewport_background_theme": "מעבר הצבעים של ערכת הנושא", "prefs.viewport_section_status": "הצגת מצב חתך בחלון התצוגה", "prefs.viewport_section_status_help": "הצגת הגדרת החתך בחלון התצוגה. Escape ולחצן הסרט הפעיל מכבים את החתך. כיבוי האפשרות שומר על בקרי החתך הקודמים.", + "prefs.viewport_status_details": "פרטים בזמן אמת בחלון התצוגה", + "prefs.viewport_status_details_help": "עריכת הגדרות הניתוח והחתך הפעילים בכרטיסי פרטים אחידים. השבתה מחזירה את מקראי הניתוח לקריאה בלבד ואת תיבת הדו-שיח של חתך.", "prefs.viewport_status_summary": "סיכום פריטי מצב בחלון התצוגה", "prefs.viewport_status_summary_help": "קיבוץ שלושה פריטי מצב או יותר לתג סיכום אחד. כיבוי האפשרות משאיר כל פריט בשורה המלאה.", "prefs.wheel": "גלגלת", @@ -3549,6 +3857,7 @@ "sketch_text.formula": "התחילו ב־= כדי לבנות את הטקסט מפרמטרים: ='W' + number(Width)", "sketch_text.italic": "נטוי", "sketch_text.loading_fonts": "מחפש גופנים מותקנים…", + "sketch_text.no_bold": "לגופן המובנה אין משקל מודגש: בחר גופן מותקן", "sketch_text.on_path": "הטקסט עוקב אחר המסלול שלו: העקומה קובעת את מיקומו", "sketch_text.path_clear": "הסרת הטקסט מהעקומה", "sketch_text.path_hint": "עקומה בסקיצה שהטקסט עוקב אחריה: יש ללחוץ כאן ואז על העקומה", @@ -3757,6 +4066,7 @@ "ui.agent_chat_auth_terminal": "יש להריץ את זה במסוף ואז ללחוץ שוב על התחברות:", "ui.agent_chat_auth_title": "התחברות לסוכן", "ui.agent_chat_cancel": "ביטול", + "ui.agent_chat_cannot_start": "מותקן, אך אינו מופעל: {error}", "ui.agent_chat_changes": "שינויים: {n}, צעד ביטול אחד", "ui.agent_chat_changes_n": "{n} שינויים", "ui.agent_chat_changes_of": "{n}/{limit} שינויים", @@ -3849,6 +4159,7 @@ "ui.agent_chat_queued_stopped": "בתור (נעצר, לא נשלח)", "ui.agent_chat_refresh": "רענון", "ui.agent_chat_refused": "אסור לסוכן: {reason}", + "ui.agent_chat_reinstall": "{agent} מותקן ({program}), אך לא הופעל כאשר SolidCraft בדק אותו: {error}\nייתכן שההתקנה שלו חלקית, או שהיא מיועדת לסוג אחר של מחשב. יש להתקין אותו מחדש עם `{install}`, לוודא שהוא מציג את הגרסה שלו במסוף, ואז ללחוץ על רענון.", "ui.agent_chat_remove": "הסרה מהתור", "ui.agent_chat_remove_file": "הסרה מההודעה הזו", "ui.agent_chat_remove_imported": "הסרה מההודעה הזו; החלק נשאר בתכן (ביטול מסיר אותו)", @@ -3912,12 +4223,17 @@ "ui.ai_copy_suffix": "עותק", "ui.ai_custom_url": "URL מותאם אישית", "ui.ai_default": "ברירת מחדל", + "ui.ai_edit": "עריכה", + "ui.ai_edit_agent_title": "עריכת סוכן", + "ui.ai_edit_gone": "הסוכן הזה הוסר, ולכן אין מה לשמור.", + "ui.ai_edit_tip": "שינוי השם, הפקודה או נקודת הקצה של סוכן זה. מפתח שמור נשאר מוסתר.", "ui.ai_http_busy": "נקודת הקצה עמוסה או חרגה מהמגבלה שלה; יש לנסות שוב מאוחר יותר", "ui.ai_http_key": "נקודת הקצה דחתה את המפתח: יש לבדוק אותו בהעדפות > AI", "ui.ai_http_not_found": "נקודת הקצה או המודל לא קיימים: יש לבדוק את URL הבסיס ואת שם המודל", "ui.ai_http_too_large": "השיחה גדולה מדי עבור נקודת קצה זו; יש להתחיל צ'אט חדש", "ui.ai_key": "מפתח API", "ui.ai_key_hint": "רשות; נשמר במחזיק המפתחות של המערכת", + "ui.ai_key_keep_hint": "יש להשאיר ריק כדי לשמור על המפתח השמור", "ui.ai_key_needs_https": "מפתח נשלח רק דרך HTTPS, או למחשב הזה.", "ui.ai_key_note": "נשמר במחזיק המפתחות של המערכת, לעולם לא בהגדרות של SolidCraft. יש להשאיר ריק עבור שרת מקומי.", "ui.ai_keychain_error": "מחזיק המפתחות של המערכת סירב: {error}", @@ -3944,11 +4260,15 @@ "ui.ai_permissions": "הרשאות", "ui.ai_preset": "תצורה מוכנה", "ui.ai_protocol": "פרוטוקול", + "ui.ai_reasoning_effort": "מאמץ ההסקה", + "ui.ai_reasoning_effort_field_hint": "ריק = ברירת המחדל של הספק", + "ui.ai_reasoning_effort_hint": "אופציונלי. נשלח לנקודת הקצה כ־`reasoning_effort`; הערכים המקובלים שונים מספק לספק (למשל `low`, `high` או `max`). SolidCraft לעולם לא תחליף את הערך שבחרת כאן. השאירו ריק כדי לא לשלוח דבר ולשמור על ברירת המחדל של הספק.", "ui.ai_remove": "הסרה", "ui.ai_request_timed_out": "המודל נזקק ליותר מ־{seconds} שניות. ניתן להגדיל את זמן ההמתנה לבקשה עבור נקודת קצה זו בהעדפות › עוזר.", "ui.ai_request_timeout": "זמן המתנה לבקשה", "ui.ai_request_timeout_hint": "כמה זמן מותר לבקשה אחת למודל בנקודת קצה זו, מ־30 שניות עד שעה. כדאי להגדיל עבור מודל מקומי איטי.", "ui.ai_reset": "איפוס", + "ui.ai_save": "שמירה", "ui.ai_settings": "הגדרות AI…", "ui.ai_show_features": "הצגת תכונות AI", "ui.ai_show_features_note": "הצ'אט עם הסוכן: חלונית שבה סוכן קוד שכבר נמצא בשימוש, או נקודת קצה של מודל לבחירתך, עובדים על התכן הפתוח. כבוי: אין לחצן AI, קיצור דרך, פריט תפריט או חלונית, ושום דבר לא מחופש או מופעל.", @@ -4108,6 +4428,7 @@ "ui.dark": "כהה", "ui.default_modelling_orientation": "כיוון מידול ברירת מחדל", "ui.delete": "מחיקה", + "ui.delete_select_first": "בחרו קודם מה למחוק: תכונות, גופים, רכיבים או גאומטריית סקיצה", "ui.depth": "עומק", "ui.design": "תכן", "ui.design_properties.author": "מחבר", @@ -4122,6 +4443,40 @@ "ui.design_properties.project": "פרויקט", "ui.design_properties.revision": "גרסה", "ui.design_saved": "נשמר ב־{path}", + "ui.details_access_caption": "הפאות מציגות אם כלי יכול להגיע אליהן מהכיוון שנבחר.", + "ui.details_angle": "זווית", + "ui.details_continuity_caption": "עריכת התפר שנבחר. שאר התפרים שומרים על ההגדרות שלהם.", + "ui.details_continuity_stale": "גאומטריה קודמת: יש להריץ שוב את הניתוח כדי לערוך את התפר הזה או להתאים את התצוגה אליו.", + "ui.details_copy_list": "העתקת הרשימה", + "ui.details_curvature": "סבולת עקמומיות", + "ui.details_curvature_caption": "הפאות נצבעות לפי רדיוס העקמומיות המקומי שלהן.", + "ui.details_direction": "כיוון הכלי", + "ui.details_draft_caption": "הפאות נצבעות לפי הזווית שלהן ביחס לכיוון החליצה.", + "ui.details_edit_seam": "עריכת תפר", + "ui.details_fixture": "דוגמת רכיב ממשק — נתוני דוגמה בלבד", + "ui.details_fixture_caption": "דוגמת רכיב ממשק עם ערכים להמחשה; זו אינה תוצאה של מודל.", + "ui.details_fixture_problem": "ממצא לדוגמה עם בעיה", + "ui.details_fixture_readout": "ערך מדידה לדוגמה", + "ui.details_flip": "היפוך", + "ui.details_gap": "פער", + "ui.details_hatch": "קיווקוו פאות החתך", + "ui.details_jump": "קפיצת עקמומיות", + "ui.details_key": "מקרא", + "ui.details_offset": "היסט", + "ui.details_plane": "מישור", + "ui.details_pull": "כיוון החליצה", + "ui.details_radius": "רדיוס", + "ui.details_readout": "ערכים נמדדים", + "ui.details_samples": "דגימות", + "ui.details_seam": "{body} · מקצוע {edge}", + "ui.details_settings": "הגדרות", + "ui.details_sharp_gap": "חד או בעל פער", + "ui.details_show_all": "הצגת הכול ({count})", + "ui.details_smooth": "חלק", + "ui.details_stripes": "פסים", + "ui.details_target": "התאמת התצוגה לגאומטריה", + "ui.details_worst_seams": "התפרים הגרועים ביותר", + "ui.details_zebra_caption": "פסי ההשתקפות מראים באיזו מידה הפאות מתחברות בצורה חלקה.", "ui.diameter": "קוטר", "ui.dimension": "מידה", "ui.direct_interaction": "אינטראקציה ישירה", @@ -4166,6 +4521,7 @@ "ui.edit_in_place_saved_elsewhere": "החלק נשמר לקובץ אחר; ההרכבה עדיין מקושרת אל {name}", "ui.edit_in_place_wait": "יש להמתין עד שמה שפועל בתכן יסתיים", "ui.edit_section_analysis": "עריכת ניתוח חתך", + "ui.edit_sketch_empty": "אין סקיצות לעריכה", "ui.ellipse": "אליפסה", "ui.emboss_wrap_note": "פרופילי קווים ניצבים המשיקים לגליל נתמך נכרכים אוטומטית. יש לבחור דופן כדי לקבוע יעד.", "ui.end_radius": "רדיוס סיום", @@ -4176,12 +4532,15 @@ "ui.equation_explicit": "מפורשת", "ui.equation_form": "צורה", "ui.equation_note": "המשתנה הוא מספר ללא יחידה: sin(t) מקבלת אותו ברדיאנים. תוצאה ללא יחידה היא ביחידה של התכנון.", + "ui.equation_note_range": "המשתנה הוא אורך, כמו הטווח שלו: אפשר לשלב אותו עם פרמטרי אורך, ולחלק אותו באורך עבור sin(). תוצאה ללא יחידה היא ביחידת התכן.", "ui.equation_parametric": "פרמטרית", + "ui.equation_variable_unit": "המשתנה ביחידת הטווח", "ui.equation_x_of_t": "x(t)", "ui.equation_y_of_t": "y(t)", "ui.equation_y_of_x": "y(x)", "ui.equivalent_radius": "רדיוס שקול", "ui.expand": "הרחבה", + "ui.explode_needs_two_components": "תצוגה מפוצצת מרחיקה רכיבים זה מזה, ולכן התכן צריך שניים או יותר מהם (רכיב חדש, הוספת תכן או יצירת רכיבים מגופים)", "ui.exportDxf": "ייצוא DXF…", "ui.export_ellipsis_keymap": "ייצוא…", "ui.export_keymap_tip": "מקשים, מועדפי תיבת S וסרגל הכלים, כקובץ", @@ -4350,12 +4709,14 @@ "ui.joints": "מפרקים", "ui.keyboard_shortcuts": "קיצורי מקלדת", "ui.language": "שפה", + "ui.lasso_select": "לאסו (גרירת לולאה חופשית: בוחר את מה שנמצא כולו בתוכה)", "ui.layout_compact": "דחוס", "ui.layout_floating": "צף", "ui.length": "אורך", "ui.length_decimals": "ספרות עשרוניות באורך", "ui.light": "בהיר", "ui.line": "קו", + "ui.line_arc_hint": "קשת משיקה: לחצו על קצה הקשת. השהו שוב על הנקודה האחרונה כדי לחזור לקווים; לחיצה ימנית או Esc לסיום", "ui.link_copies": "קישור עותקים זהים", "ui.link_copies_keep": "השארה", "ui.link_copies_link": "קישור", @@ -4444,6 +4805,7 @@ "ui.part_size_shared": "בונה מחדש את החלק ומחליף פיצ'רים שנוספו. כל המופעים של רכיב זה מתעדכנים.", "ui.paste": "הדבקה", "ui.path": "מסלול", + "ui.pattern_object_type_tip": "גופים: צורות מלאות, נבחרות בתצוגה או בעץ התכן; כל אחד מועתק בשלמותו.\nפיצ'רים: שלבים בציר הזמן, כמו שליפה או חור, נבחרים בציר הזמן או על פאה שהם יצרו; השלב חוזר על עצמו.\nסקיצה אינה אף אחד מהם: חזרו על העקומות שלה בעזרת המערך או השיקוף של הסקיצה עצמה, או בצעו לה שליפה קודם.", "ui.pattern_on_path": "מערך לאורך מסלול", "ui.perforate_margin_default": "לפי הקוטר", "ui.perforate_pitch": "פסיעה", @@ -4509,6 +4871,7 @@ "ui.properties.inertia_y": "Iyx, Iyy, Iyz", "ui.properties.inertia_z": "Izx, Izy, Izz", "ui.properties.measuring": "מודד…", + "ui.properties.measuring_bodies": "מודד… {done} מתוך {total} גופים", "ui.properties.measuring_failed": "המדידה נעצרה לפני שהסתיימה.", "ui.properties.principal_axis": "ציר ראשי {number}", "ui.properties.principal_moments": "מומנטים ראשיים", diff --git a/assets/locales/hr.json b/assets/locales/hr.json index d69697c65f..1c4c759bec 100644 --- a/assets/locales/hr.json +++ b/assets/locales/hr.json @@ -60,6 +60,7 @@ "BLEND SURFACE": "PRIJELAZNA PLOHA", "BOUNDARY SURFACE": "GRANIČNA PLOHA", "BROWSER": "STABLO MODELA", + "Back to the original order": "Natrag na izvorni redoslijed", "Ball": "Kuglasti", "Bar": "Šipka", "Base Center": "Središte osnove", @@ -595,6 +596,8 @@ "Snap to face and edge centres": "Privlači na središta lica i bridova", "SolidCraft cannot submit reports directly. Submit on GitHub; no token is needed in the app.": "SolidCraft ne može izravno slati izvješća. Pošaljite ih na GitHubu; u aplikaciji nije potreban token.", "SolidCraft closed before these designs were saved. Their last autosave can be opened.": "SolidCraft se zatvorio prije nego što su ovi dizajni spremljeni. Može se otvoriti njihovo posljednje automatsko spremanje.", + "Sort A to Z": "Poredaj od A do Ž", + "Sort Z to A": "Poredaj od Ž do A", "Spacing 2 must be a non-zero number": "Razmak 2 mora biti broj različit od nule", "Spacing must be a non-zero number": "Razmak mora biti broj različit od nule", "Specialise": "Specijaliziraj", @@ -819,6 +822,7 @@ "cmd.appearance_pick_up": "Preuzmi izgled", "cmd.appearance_replace": "Zamijeni izgled", "cmd.assembly_bom": "Sastavnica", + "cmd.assembly_mass_properties": "Svojstva mase", "cmd.body.print_face": "Ploha ispisa", "cmd.body_rename": "Preimenuj tijelo", "cmd.browser_collapse_all": "Sažmi sve", @@ -1244,6 +1248,7 @@ "cmd.view.render_browser": "Stablo renderiranja", "cmd.view.render_dock": "Premjesti panel renderiranja", "cmd.view.render_focus": "Fokusni prikaz renderiranja", + "cmd.view.status_details": "Detalji statusa prozora prikaza", "cmd.view.status_items": "Statusne stavke prozora prikaza", "cmd.view_ambient_occlusion": "Ambijentalna okluzija", "cmd.view_angle_readout": "Kutovi prikaza", @@ -1387,21 +1392,168 @@ "from the rule": "iz pravila", "help.apache": "Apache licenca 2.0", "help.appearance.assign": "Daje odabranim tijelima, licima ili komponentama boju ili izgled materijala, bez promjene njihove težine. Upotrijebite ga kako biste razlikovali dijelove ili pregledali završnu obradu.", + "help.assembly.bom": "Navodi dijelove dizajna ili komponente s količinama, po broju dijela, a popis može spremiti kao CSV ili JSON. Upotrijebite ga kao popis dijelova za narudžbu i montažu.", "help.attributions": "Atribucije (ATTRIBUTION.md)", "help.build": "Verzija {version} · commit {commit} · izgrađeno {date}", + "help.canvas.decal": "Postavlja sliku na ravno lice modela, poput naljepnice. Upotrijebite ga kako biste na dijelu prikazali oznaku, logotip ili otisnuti uzorak.", + "help.canvas.edit": "Mijenja platno: njegov položaj, veličinu, kut, neprozirnost, zrcaljenje, vidljivost ili naziv. Upotrijebite ga kako biste referentnu sliku poravnali sa skicom koju po njoj ocrtavate.", + "help.canvas.insert": "Postavlja PNG ili JPEG sliku na ravninu ili lice kao prozirnu referencu. Upotrijebite ga kako biste u skici ocrtali fotografiju ili crtež, sa slikom skaliranom na poznatu veličinu.", + "help.component.break_link": "Odvaja povezanu komponentu od njezine datoteke dizajna, a njezina tijela ostaju onakva kakva jesu. Upotrijebite ga kad dio više ne treba pratiti promjene u izvornoj datoteci.", + "help.component.capture_position": "Zadržava položaje dijelova koje ste ručno pomaknuli, tako da ti dijelovi ostaju na tim mjestima. Upotrijebite ga nakon povlačenja dijelova na mjesto koje nijedan spoj ne drži.", + "help.component.create": "Dodaje novu, praznu komponentu u dizajn, unutar aktivne komponente osim ako odaberete drugu nadređenu komponentu. Upotrijebite ga kako biste novom dijelu sklopa dali vlastito mjesto za tijela i skice.", "help.component.finish_edit": "Sprema dio koji uređujete na mjestu, vraća se u njegov sklop i ažurira sklop prema njemu. Upotrijebite je kad završite izmjene povezanog dijela.", + "help.component.from_bodies": "Pretvara svako odabrano tijelo u zasebnu komponentu, nazvanu po tijelu. Upotrijebite ga kad dizajn izgrađen od samostalnih tijela treba postati sklop čije se dijelove može spajati i pomicati.", + "help.component.insert_design": "Umeće drugi spremljeni dizajn kao komponentu povezanu s njegovom datotekom, a njegova puna tijela postavlja onako kako su u njemu. Upotrijebite ga kako biste dio ponovno upotrijebili u sklopu i preuzeli kasnije promjene naredbom Ažuriraj veze.", + "help.component.properties": "Prikazuje volumen, masu, središte mase i momente tromosti komponente, prema materijalima njezinih tijela. Upotrijebite ga kako biste provjerili težinu i ravnotežu sklopa.", + "help.component.revert_position": "Vraća ručno pomaknute dijelove na mjesto na kojem su bili, odbacujući pomake koji nisu zabilježeni. Upotrijebite ga kako biste poništili probne pomake u sklopu.", + "help.component.update_links": "Ponovno čita povezane datoteke dizajna i zamjenjuje tijela koja su se promijenila. Upotrijebite ga nakon uređivanja dijela koji ovaj sklop koristi.", + "help.config.configure": "Dodaje parametre, značajke, materijale ili izglede kao stupce tablice konfiguracija. Upotrijebite ga kako biste izradili inačice jednog dizajna, primjerice veličine nekog dijela.", + "help.config.preview": "Provjerava može li se redak tablice konfiguracija ponovno izgraditi, bez promjene dizajna ili njegove vremenske trake. Upotrijebite ga prije prebacivanja na inačicu u čiju ispravnost niste sigurni.", + "help.config.rules": "Prikazuje pravila tablice konfiguracija, njezine retke i koji je redak aktivan. Upotrijebite ga kako biste vidjeli kako su postavljene inačice dizajna.", + "help.config.table": "Prikazuje tablicu konfiguracija: jedan redak po inačici, jedan stupac po parametru ili značajki, te podudara li se dizajn još s aktivnim retkom. Upotrijebite ga za pregled inačica dizajna.", "help.connect_note": "Ponovno pokrenite svakog asistenta kako bi učitao SolidCraft poslužitelj. Svaka datoteka sigurnosno je kopirana kao .bak prije promjene.", "help.connect_title": "Poveži AI asistente", + "help.construct.axis.edge": "Izrađuje pomoćnu os duž ravnog brida ili linije skice. Upotrijebite je kao os za rotaciju, kružni niz ili spoj.", + "help.construct.axis.normal_to_face": "Izrađuje pomoćnu os kroz točku, okomito na lice. Upotrijebite je kao os koja stoji ravno iz površine.", + "help.construct.axis.through_cylinder": "Izrađuje pomoćnu os kroz središte lica valjka, stošca ili torusa ili kružnog brida. Upotrijebite je za okretanje ili umnožavanje značajki oko okruglog dijela.", + "help.construct.axis.two_planes": "Izrađuje pomoćnu os na mjestu gdje se dvije ravnine ili ravna lica sijeku. Upotrijebite je kad je potrebna os pravac između dvaju lica.", + "help.construct.axis.two_points": "Izrađuje pomoćnu os kroz dvije točke, vrhove ili točke skice. Upotrijebite je za os koja prolazi između dvaju poznatih položaja.", + "help.construct.plane.along_path": "Izrađuje pomoćnu ravninu okomitu na brid ili krivulju, na položaju duž njih. Upotrijebite je za skiciranje profila za povlačenje ili presjeka putanje.", + "help.construct.plane.at_angle": "Izrađuje pomoćnu ravninu zakrenutu za kut u odnosu na ravninu ili lice, oko osi ili brida. Upotrijebite je za nagnute skice, primjerice kutnog nosača.", + "help.construct.plane.midplane": "Izrađuje pomoćnu ravninu na pola puta između dviju ravnina ili ravnih lica, ili ravninu koja raspolavlja kut kad se one sijeku pod kutom. Upotrijebite je kao ravninu simetrije za zrcaljenje.", + "help.construct.plane.offset": "Izrađuje pomoćnu ravninu paralelnu s ravninom ili ravnim licem, na određenoj udaljenosti ili kroz točku. Upotrijebite je za skiciranje na zadanoj visini iznad lica.", + "help.construct.plane.perpendicular": "Izrađuje pomoćnu ravninu koja sadrži liniju ili brid i okomita je na ravninu ili lice. Upotrijebite je za skiciranje uspravno na modelu duž brida.", + "help.construct.plane.tangent": "Izrađuje pomoćnu ravninu koja u jednoj točki dodiruje valjak, stožac, kuglu ili torus. Upotrijebite je za skiciranje na boku okruglog dijela.", + "help.construct.plane.three_points": "Izrađuje pomoćnu ravninu kroz tri točke. Upotrijebite je za ravninu određenu položajima na modelu, primjerice trima kutovima.", + "help.construct.plane.two_edges": "Izrađuje pomoćnu ravninu kroz dva ravna brida ili linije koji leže u istoj ravnini. Upotrijebite je za ravninu kroz dio, primjerice kroz dva nasuprotna brida.", + "help.construct.point.along_path": "Izrađuje pomoćnu točku na položaju duž brida ili krivulje. Upotrijebite je kao referencu negdje na putanji.", + "help.construct.point.center": "Izrađuje pomoćnu točku u središtu kružnog brida, kružnice ili luka skice, kugle ili torusa. Upotrijebite je kao referencu za provrte, osi i spojeve.", + "help.construct.point.edge_plane": "Izrađuje pomoćnu točku na mjestu gdje brid ili linija prolazi kroz ravninu ili ravno lice. Upotrijebite je za označavanje mjesta gdje putanja siječe ravninu.", + "help.construct.point.three_planes": "Izrađuje pomoćnu točku na mjestu gdje se sijeku tri ravnine ili ravna lica. Upotrijebite je kao referencu kuta, čak i tamo gdje model nema vrh.", + "help.construct.point.two_edges": "Izrađuje pomoćnu točku na mjestu gdje se dva brida ili linije sijeku ili si najviše približe. Upotrijebite je kao referencu u virtualnom kutu.", + "help.construct.point.vertex": "Izrađuje pomoćnu točku na vrhu ili točki skice. Upotrijebite je kao referencu koja ostaje kad se skica ili tijelo oko nje sakriju.", + "help.contact.check": "Navodi parove dijelova u skupovima kontakata koji u svojem trenutnom položaju prolaze jedan kroz drugi. Upotrijebite ga za pronalaženje sudara prije pokretanja spojeva ili studije kretanja.", + "help.contact.create": "Grupira dva ili više dijelova u skup kontakata čija se tijela tijekom pomicanja spojeva sprječavaju da prolaze jedno kroz drugo. Upotrijebite ga za dijelove koji se trebaju zaustaviti kad se dodirnu, primjerice polugu uz njezin graničnik.", + "help.contact.disable_all": "Isključuje kontakt, pa se pokretani spojevi i studije kretanja više ne zaustavljaju tamo gdje se dijelovi dodiruju. Upotrijebite ga za ponovno slobodno pomicanje dijelova ili za bolji odziv velikog sklopa.", + "help.contact.edit": "Mijenja skup kontakata: dijelove koje sadrži, njegov naziv ili je li potisnut; može i izbrisati skup. Upotrijebite ga kako biste prilagodili koji se dijelovi sprječavaju da prolaze jedan kroz drugi.", + "help.contact.enable_all": "Stavlja svaki dio sklopa u jedan skup kontakata, pa nijedna dva dijela ne mogu proći jedan kroz drugi tijekom pomicanja spojeva. Upotrijebite ga za brzu provjeru cijelog mehanizma.", + "help.contact.enable_sets": "Uključuje kontakt za skupove kontakata koje ste definirali, pa se pokretani spojevi i studije kretanja zaustavljaju tamo gdje bi njihovi dijelovi prošli jedan kroz drugi. Upotrijebite ga kako biste vratili svoje skupove nakon isključivanja kontakta.", + "help.contact.list": "Navodi skupove kontakata dizajna, dijelove u svakome od njih i je li kontakt uključen. Upotrijebite ga za provjeru kako je kontakt postavljen prije pokretanja sklopa.", + "help.document.inspect": "Prikazuje pregled dizajna: njegove komponente, tijela, skice, značajke i parametre. Upotrijebite ga kako biste se snašli u dizajnu koji niste sami izradili.", + "help.document.units": "Mijenja jedinice u kojima dizajn prikazuje duljine, primjerice mm ili inče. Geometrija zadržava svoju veličinu; mijenjaju se samo prikaz i obični brojevi.", + "help.edit.checkpoint": "Pamti dizajn, aktivnu skicu i povijest poništavanja onakve kakvi su sada. Upotrijebite ga prije isprobavanja nečeg rizičnog od čega ćete se možda htjeti vratiti.", + "help.edit.restore_checkpoint": "Vraća se na kontrolnu točku, uključujući povijest poništavanja. Upotrijebite ga kako biste jednim korakom odbacili sve učinjeno od kontrolne točke.", + "help.explode.create": "Izrađuje rastavljeni prikaz koji dijelove odmiče od središta sklopa ili prema pomacima koje zadate. Upotrijebite ga za prikaz sastavljanja sklopa, za upute ili prezentacije.", "help.face.delete": "Uklanja odabrana lica i pušta da susjedna lica zatvore otvor, ili ostavlja otvor otvorenim kao plohu. Upotrijebite ga kako biste uklonili zaobljeni brid, provrt ili ispupčenje koje smeta.", + "help.file.insert_mesh": "Dodaje 3MF, STL, OBJ, PLY ili glTF datoteku u dizajn kao mrežna tijela u novoj komponenti. Upotrijebite ga kako biste preuzeti ili skenirani model postavili uz svoj dizajn kao referencu.", + "help.file.insert_step": "Dodaje STEP, IGES, FreeCAD ili Rhino datoteku u dizajn kao puna tijela u novoj komponenti; dijelovi sklopa postaju ugniježđene komponente. Upotrijebite ga za unos modela od dobavljača ili iz drugih CAD programa.", + "help.file.save_mesh": "Sprema tijela kao STL, 3MF ili OBJ mrežu, s glatkoćom zakrivljenih lica po vašem izboru. Upotrijebite ga za pripremu dijelova za 3D ispis ili za programe koji čitaju mreže.", + "help.file.send_to_slicer": "Sprema odabrana tijela kao 3MF datoteku i otvara je u slicer programu koji odaberete. Upotrijebite ga za prijelaz s modela na ispis u jednom koraku.", "help.font_details": "Sučelje koristi ugrađene fontove biblioteke egui (Ubuntu Light i Hack, pod licencama Ubuntu Font Licence i MIT/Bitstream Vera; Noto Emoji i font emoji ikona, pod licencama OFL i MIT). Tekst skice koristi Liberation Sans (SIL OFL 1.1). Neobavezni paket fontova Crafting Apps, craft-fonts (BIZ UDPGothic, Shippori Mincho, BIZ UDMincho, Noto Sans CJK SC, Noto Sans Arabic; SIL OFL 1.1), nije uključen u izdanja SolidCrafta.", "help.fonts": "Fontovi", + "help.frame.member": "Postavlja profil, primjerice cijev ili aluminijski ekstrudirani profil, duž linija skice okvira, jedan element po liniji. Upotrijebite ga za izradu okvira, regala i zavarenih konstrukcija.", + "help.inspect.accessibility": "Boji lica do kojih alat može doći iz jednog smjera i ona do kojih ne može. Upotrijebite ga za provjeru dijela za strojnu obradu ili kalupljenje s te strane.", + "help.inspect.center_of_mass": "Prikazuje središte mase tijela, svakog zasebno i svih zajedno, pri jednolikoj gustoći. Upotrijebite ga za provjeru ravnoteže i mjesta na kojem će se dio prevrnuti.", + "help.inspect.colored_components": "Daje svakoj komponenti vlastitu privremenu boju, bez promjene izgleda. Upotrijebite ga kako biste na prvi pogled razlikovali dijelove sklopa.", + "help.inspect.compare": "Uspoređuje dva tijela i prikazuje gdje jedno ima materijala kojeg drugo nema, s volumenom i odstupanjem. Upotrijebite ga za provjeru izmijenjenog ili uvezenog dijela u odnosu na izvornik.", + "help.inspect.continuity": "Provjerava koliko glatko se lica spajaju duž svojih bridova (razmake, lomove ili skokove zakrivljenosti) i sjenčanjem označava šavove. Upotrijebite ga za pronalaženje mjesta na kojima se plohe ne spajaju čisto.", + "help.inspect.curvature_comb": "Crta češljeve duž krivulja, bridova ili lica čiji zupci pokazuju koliko se snažno savijaju. Upotrijebite ga za uočavanje ravnih mjesta, lomova i neujednačene zakrivljenosti.", + "help.inspect.curvature_map": "Boji lica prema jakosti zakrivljenosti, crveno tamo gdje je polumjer malen. Upotrijebite ga za pronalaženje uskih mjesta i procjenu glatkoće plohe.", + "help.inspect.draft": "Boji lica prema kutu nagiba u odnosu na smjer vađenja iz kalupa i označava ona s premalo nagiba. Upotrijebite ga za provjeru dijela prije kalupljenja ili lijevanja.", + "help.inspect.environment_map": "Prikazuje model s reflektirajućom završnom obradom nalik zrcalu. Upotrijebite ga za procjenu toka ploha prema načinu na koji se odrazi pružaju preko njih.", + "help.inspect.interference": "Pronalazi tijela koja se preklapaju i prikazuje svako preklapanje s njegovim volumenom. Upotrijebite ga za provjeru sudaraju li se dijelovi u sklopu.", + "help.inspect.isocurve": "Crta izoparametarske krivulje lica, mrežu na kojoj je ploha izgrađena. Upotrijebite ga kako biste vidjeli kako ploha teče i gdje se zgušnjava.", + "help.inspect.measure": "Mjeri duljine, udaljenosti, kutove, površine i polumjere među elementima koje odaberete, ili volumen i masu tijela. Upotrijebite ga za provjeru mjera i zazora.", + "help.inspect.minimum_radius": "Pronalazi najmanji polumjer zakrivljenosti krivulja, bridova ili lica i mjesto na kojem se nalazi. Upotrijebite ga kako biste provjerili može li alat ili mlaznica pratiti oblik.", + "help.inspect.section": "Presijeca prikaz duž ravnine i šrafira presječena lica, bez promjene modela. Upotrijebite ga za pogled u unutrašnjost dijelova i sklopova.", + "help.inspect.zebra": "Prikazuje crne i bijele pruge odražene na licima modela. Upotrijebite ga za procjenu glatkoće: pruge koje se prekidaju ili lome na bridu upućuju na loš prijelaz.", + "help.joint.as_built": "Spaja dva dijela tamo gdje već jesu, bez pomicanja ijednog od njih, i određuje kako se još smiju kretati. Upotrijebite ga za dijelove koje ste već postavili u konačni položaj.", + "help.joint.cam": "Povezuje rotirajući brijeg sa sljedbenikom, pa sljedbenik klizi ili se njiše dok ispod njega prolazi obris brijega. Upotrijebite ga za brijegove, ekscentre i pogone ventila.", + "help.joint.create": "Spaja dva dijela na ishodištima spojeva koja odaberete i pomiče drugi dio na mjesto; vrsta spoja određuje kako se još smiju kretati. Upotrijebite ga za izradu sklopa čiji se dijelovi kreću poput stvarnog mehanizma.", + "help.joint.drive": "Postavlja kut ili udaljenost spoja i s njim pomiče dijelove, unutar njegovih granica; povezani spojevi i zatvorene petlje slijede ga. Upotrijebite ga za provjeru kreće li se mehanizam kako je zamišljeno.", + "help.joint.gear": "Povezuje dva rotirajuća spoja prijenosnim omjerom, pa pokretanje jednog okreće drugi. Upotrijebite ga za čelne zupčanike i zupčanike s unutarnjim ozubljenjem, s omjerom iz polumjera diobenih kružnica ili broja zubaca.", + "help.joint.limits": "Određuje koliko se spoj smije zakrenuti ili pomaknuti, kao najmanju i najveću vrijednost, ili briše te granice. Upotrijebite ga kako bi šarka, klizač ili poluga ostali unutar svojeg stvarnog raspona kretanja.", + "help.joint.motion_link": "Povezuje dva spoja omjerom i odmakom, pa pokretanje jednog pomiče drugi. Upotrijebite ga za čvrst odnos među spojevima koji nije pokriven naredbama Zupčanik, Zupčasta letva i zupčanik ili Vijak.", + "help.joint.origin": "Postavlja imenovano ishodište spoja na dio: u središte lica ili okruglog kraja, sredinu brida, kut ili točku. Upotrijebite ga za pripremu mjesta na kojima se spojevi vežu; ishodište odabrano na licu prati ga kad dio promijeni veličinu.", + "help.joint.rack_pinion": "Povezuje rotirajući mali zupčanik s kliznom zupčastom letvom, pa se letva pomiče za duljinu diobene kružnice koju mali zupčanik prevali. Upotrijebite ga za pogone s letvom, upravljanje i linearne stolove.", + "help.joint.reach": "Pomiče točku na dijelu prema cilju mijenjajući spojeve na njezinu lancu, unutar njihovih granica. Upotrijebite ga za namještanje mehanizma prema tome gdje mu treba biti kraj, primjerice vrh ruke robota.", + "help.joint.rigid_group": "Zaključava odabrane dijelove zajedno onakve kakvi jesu, pa se pomiču kao jedan. Upotrijebite ga za dijelove koji pripadaju zajedno i među kojima nema kretanja.", + "help.joint.screw": "Povezuje okretanje i klizanje korakom, pa svaki okretaj pomiče za tu udaljenost. Upotrijebite ga za vodeće vijke, vijke i navojna vretena.", + "help.library.apply": "Primjenjuje materijal ili izgled iz lokalne datoteke biblioteke na tijela, lica ili komponente. Upotrijebite ga za ponovnu upotrebu materijala koje ste spremili iz drugog dizajna.", + "help.library.export": "Sprema materijale i izglede dizajna u datoteku biblioteke. Upotrijebite ga za njihovo dijeljenje ili ponovnu upotrebu u drugim dizajnima.", + "help.library.inspect": "Provjerava datoteku biblioteke i navodi njezine materijale i izglede, bez promjene dizajna. Upotrijebite ga kako biste prije primjene vidjeli što biblioteka sadrži.", "help.licences": "Licence", "help.material.assign": "Određuje od čega su odabrana tijela izrađena, npr. čelik, aluminij ili ABS. Upotrijebite ga kako bi masa i težina odgovarale stvarnom dijelu.", + "help.material.properties.validate": "Provjerava karticu podataka o materijalu radi dosljednosti brojeva i vrijednosti koje nedostaju. Upotrijebite ga prije nego se na podatke oslonite za masu ili krutost.", + "help.mesh.convert": "Ponovno izrađuje mrežno tijelo, primjerice STL, kao uredivo puno tijelo s ravnim i cilindričnim licima. Upotrijebite ga za izmjenu uvezenih strojnih dijelova kao da su modelirano tijelo.", "help.mit": "MIT licenca", + "help.motion.study": "Postavlja vrijednosti spojeva u koracima duž vremenske trake i reproducira ih kao animaciju. Upotrijebite ga za praćenje kretanja mehanizma kroz slijed i provjeru zazora usput.", + "help.motion.trace": "Prati putanju kojom ide točka na dijelu dok se reproducira studija kretanja ili pokreće spoj i, nakon potvrde, zadržava je kao krivulju 3D skice. Upotrijebite ga kako biste vidjeli dokle mehanizam doseže ili kako biste gradili na njegovoj putanji.", "help.notice": "NOTICE", + "help.occurrence.ground": "Učvršćuje dio na mjestu ili ga ponovno oslobađa, pa spojevi pomiču ostale dijelove oko njega. Upotrijebite ga za okvir ili podnožje na kojem je izgrađen ostatak sklopa.", "help.occurrence.move": "Pomiče dio sklopa samo u stupnjevima slobode koje dopuštaju njegovi spojevi. Odaberite lice dijela, a zatim povucite dostupnu strelicu ili luk.", "help.parameters.change": "Otvara tablicu imenovanih vrijednosti i izraza koji upravljaju kotama dizajna. Upotrijebite je kako biste promijenili mjeru na jednom mjestu, a sve značajke koje je koriste je slijede.", + "help.parameters.export": "Sprema parametre dizajna kao CSV ili JSON. Upotrijebite ga za uređivanje u proračunskoj tablici ili prijenos u drugi dizajn.", + "help.parameters.graph": "Prikazuje koji parametri i značajke koriste koje druge, te petlje ili pogreške među njima. Upotrijebite ga kako biste vidjeli na što će utjecati promjena parametra.", + "help.parameters.import": "Čita parametre iz CSV ili JSON datoteke u dizajn, i to sve ili nijedan. Upotrijebite ga za unos vrijednosti iz proračunske tablice ili drugog dizajna.", + "help.parameters.move": "Premješta korisnički parametar ispred ili iza drugog na popisu. Upotrijebite ga za držanje povezanih parametara zajedno.", + "help.parts.fastener": "Dodaje standardni vijak, svornjak, maticu, podlošku, zatik ili ležaj u odabranoj veličini. Postavljen na provrt, smješta se u njega spojem, pa spojne elemente ne morate modelirati sami.", + "help.parts.insert": "Umeće standardni dio u odabranoj obitelji, veličini i duljini. Postavljen na rub ili stijenku provrta, smješta se na provrt krutim spojem.", + "help.parts.library": "Otvara standardne dijelove: vijke, svornjake, matice, podloške, bezglave vijke, cilindrične zatike i ležajeve u standardnim veličinama. Upotrijebite ga za dodavanje standardnog dijela umjesto da ga sami modelirate.", + "help.parts.resize": "Mijenja veličinu ili duljinu umetnutog standardnog dijela i ponovno ga izgrađuje; njegovi spojevi ga drže na mjestu. Upotrijebite ga kad se vijak pokaže predugim ili provrtu treba druga veličina.", + "help.plastic.assign_rule": "Dodjeljuje pravilo za plastiku tijelima, pa njihove značajke za plastiku preuzimaju njegov nagib i zazor kao zadane vrijednosti. Upotrijebite ga za jednokratno postavljanje oblikovanog dijela za sve njegove značajke.", + "help.plastic.boss": "Dodaje ispust za vijak na licu: stup s neobaveznim provrtom, nagibom, zaobljenjem korijena i potpornim rebrima. Upotrijebite ga za točke vijaka u kalupljenim ili ispisanim kućištima.", + "help.plastic.lip": "Dodaje usnu ili odgovarajući utor uz rub tijela s ljuskom. Upotrijebite ga kako bi se dvije polovice kućišta naslanjale jedna na drugu i čvrsto zatvarale.", + "help.plastic.manage_rules": "Izrađuje i uređuje pravila za plastiku: materijal, debljinu stijenke, polumjere, zazor i nagib. Upotrijebite ih kako bi značajke oblikovanog dijela ostale dosljedne.", + "help.plastic.rest": "Dodaje oslonac na licu: okruglu ili pravokutnu podlogu, punu ili šuplju, s nagibom. Upotrijebite ga kao potporu ili distancu u oblikovanom kućištu.", + "help.plastic.snap_fit": "Dodaje kuku za škljocajući spoj na licu, s njezinom duljinom, debljinom i zapinjačem. Upotrijebite ga za spajanje dijelova kopčanjem, bez vijaka.", "help.pointcloud.insert": "Umetnite PLY, XYZ ili PTS skeniranje kao oblak točaka uz model. Koristite ga kao referencu pri izradi modela iz izmjerenih podataka.", + "help.sheet.convert": "Pretvara ploču ili tijelo s ljuskom ujednačene debljine u tijelo od lima, sa savijanjima na bridovima koje odaberete. Upotrijebite ga kako biste postojeći dio učinili razvijivim.", + "help.sheet.export_bend_table": "Sprema tablicu savijanja tijela od lima kao CSV ili JSON: kutove, polumjere i položaje savijanja u razvijenom obliku. Upotrijebite ga za stroj za savijanje ili radionicu.", + "help.sheet.export_dxf": "Sprema razvijeni oblik kao DXF, s obrisima, linijama savijanja i neobaveznim bilješkama o savijanju. Upotrijebite ga za lasersko, plazma ili vodenim mlazom rezanje.", + "help.sheet.flange": "Dodaje prirubnicu: osnovnu ploču iz skice, stijenku duž otvorene konture skice ili savijenu stijenku na bridovima lima. Upotrijebite je za izgradnju dijelova od lima.", + "help.sheet.flat_pattern": "Izrađuje razvijeni oblik tijela od lima, s njegovom veličinom, izrezima i linijama savijanja. Upotrijebite ga za provjeru pripremka prije rezanja.", + "help.sheet.fold": "Presavija lim duž linije skice za zadani kut i polumjer. Upotrijebite ga za savijanje ravnog lima nakon što ste u njega urezali značajke.", + "help.sheet.hem": "Preklapa rub lima sam na sebe. Upotrijebite ga za sigurne, krute rubove bez oštrih srha.", + "help.sheet.manage_rules": "Izrađuje i uređuje pravila za lim: debljinu, polumjer savijanja, K-faktor, rasterećenja i razmake. Upotrijebite ih za usklađivanje s materijalom i alatima vaše radionice.", + "help.sheet.path_flange": "Dodaje lanac prirubnica duž ruba lima, prateći otvoreni lanac linija skice. Upotrijebite je za profile poput U ili Z oblika u jednom koraku.", + "help.sheet.perforate": "Dodaje polje provrta po cijelom ravnom limu, u pomaknutom ili kvadratnom rasporedu. Upotrijebite ga za ventilacijske otvore, rešetke i lakše ploče.", + "help.sheet.refold": "Ponovno savija razvijeno tijelo od lima. Upotrijebite ga nakon dodavanja značajki ravnom limu.", + "help.sheet.unfold": "Razvija sva savijanja tijela od lima. Upotrijebite ga za dodavanje provrta ili izreza koji moraju prelaziti preko savijanja, a zatim ponovno presavijte.", + "help.sketch.arc.center_point": "Crta kružni luk oko središnje točke, od početne do završne točke. Upotrijebite ga kad je središte luka poznato, primjerice za polumjer oko provrta ili okretišta.", + "help.sketch.arc.tangent": "Crta luk koji počinje na kraju linije ili luka i nastavlja se tangentno na njega. Upotrijebite ga za nastavak lanca glatkim zavojem umjesto kutom.", + "help.sketch.arc.three_point": "Crta luk od početne do završne točke kroz treću točku. Upotrijebite ga kad luk mora prolaziti kroz zadane točke, a ne oko poznatog središta.", + "help.sketch.arc3d": "Crta točan kružni luk u prostoru kroz tri točke, uz uključenu 3D skicu. Upotrijebite ga za zavoje u putanjama i vodilicama koji izlaze iz ravnine skice.", + "help.sketch.auto_constrain": "Dodaje ograničenja koja skica već gotovo ima, primjerice vodoravnost, paralelnost ili podudarnost, a zatim je kotira dok nije potpuno ograničena. Upotrijebite ga za učvršćivanje grubo nacrtane skice u jednom koraku.", + "help.sketch.auto_constrain_from_datum": "Radi kao Automatska ograničenja, ali položaje mjeri od referentne točke koju odaberete umjesto od ishodišta. Upotrijebite ga kad je dio raspoređen od jedne referentne točke, primjerice kuta ili provrta.", + "help.sketch.blend_curve": "Spaja krajeve dviju krivulja glatkim splineom koji je tangentan na obje, ili i kontinuiran po zakrivljenosti ako tako odaberete. Upotrijebite ga za premošćivanje razmaka među krivuljama bez loma.", + "help.sketch.break": "Dijeli krivulju na dijelove tamo gdje je sijeku druge krivulje ili u točkama koje na nju postavite. Upotrijebite ga kad dio krivulje treba vlastito ograničenje ili ga treba zasebno izbrisati.", + "help.sketch.cancel": "Napušta skicu i odbacuje sve promjene učinjene od njezina stvaranja ili otvaranja za uređivanje. Naredba Poništi vraća promjene.", + "help.sketch.centerline": "Prebacuje linije između običnih linija i središnjih linija, osi koje profili izostavljaju. Središnju liniju upotrijebite kao os za rotaciju, zrcaljenje ili ograničenje simetrije.", + "help.sketch.chamfer.distance_angle": "Skošuje kut između dviju linija, zadan udaljenošću duž prve linije i kutom. Upotrijebite ga kad crtež daje skošenje kao duljinu i kut, primjerice 2 mm × 30°.", + "help.sketch.chamfer.equal_distance": "Skošuje kut između dviju linija jednakom udaljenošću unatrag duž obiju. Upotrijebite ga za uobičajeno skošenje od 45° na obrisu.", + "help.sketch.chamfer.two_distance": "Skošuje kut između dviju linija, zadan zasebnom udaljenošću duž svake linije. Upotrijebite ga za skošenja koja nisu 45°, zadana kao dvije duljine.", + "help.sketch.circle.center": "Crta kružnicu iz njezina središta i promjera. Upotrijebite ga za provrte, ispuste i druge okrugle značajke čije je središte poznato.", + "help.sketch.circle.three_point": "Crta kružnicu kroz tri točke. Upotrijebite ga kad kružnica mora prolaziti kroz zadane točke, primjerice postojeće geometrije.", + "help.sketch.circle.three_tangent": "Crta kružnicu koja dodiruje tri elementa (linije, kružnice ili lukove). Upotrijebite ga za uklapanje kružnice u prostor među tri krivulje, primjerice upisane kružnice trokuta.", + "help.sketch.circle.two_point": "Crta kružnicu iz dvaju krajeva njezina promjera. Upotrijebite ga kad su poznate rubne točke kružnice, a ne njezino središte.", + "help.sketch.circle.two_tangent": "Crta kružnicu zadane veličine koja dodiruje dva elementa (linije, kružnice ili lukove). Upotrijebite ga za zaobljenje stalnog polumjera između dviju krivulja.", + "help.sketch.conic": "Crta koniku između dviju krajnjih točaka, oblikovanu vršnom točkom i vrijednošću rho. Upotrijebite ga za glatke prijelaze čiju punoću želite podesiti, od ravnog do gotovo oštrog kuta.", + "help.sketch.constrainer": "Primjenjuje ograničenje koje jedna ili dvije odabrane krivulje već gotovo imaju, primjerice vodoravnost, paralelnost, tangentnost ili jednakost. Upotrijebite ga za sređivanje ručno nacrtane skice jedan po jedan odnos.", + "help.sketch.constraint.coincident": "Čini da točka leži na drugoj točki, krivulji ili osi skice. Upotrijebite ga za zatvaranje razmaka u profilu ili za postavljanje točke na liniju.", + "help.sketch.constraint.collinear": "Čini da dvije linije leže na istom pravcu. Upotrijebite ga za poravnanje prekinutih rubova, primjerice obiju strana zareza.", + "help.sketch.constraint.concentric": "Daje dvjema kružnicama ili lukovima isto središte. Upotrijebite ga za prstenove, cilindrična upuštenja i stepenaste osovine.", + "help.sketch.constraint.curvature": "Čini da dvije krivulje koje se susreću u krajnjoj točki nastave s istom zakrivljenošću (G2). Upotrijebite ga tamo gdje tangentni prijelaz nije dovoljno gladak, primjerice na vidljivim plohama.", + "help.sketch.constraint.equal": "Izjednačuje duljine linija ili polumjere kružnica i lukova. Upotrijebite ga kako bi jedna kota upravljala s više istovjetnih elemenata.", + "help.sketch.constraint.fix": "Fiksira točke ili krivulje tamo gdje jesu ili ih ponovno oslobađa. Upotrijebite ga za geometriju koja se ne smije pomaknuti kad povlačite ili rješavate skicu.", + "help.sketch.constraint.horizontal_vertical": "Čini liniju, ili liniju između dviju točaka, vodoravnom ili okomitom, što god je bliže. Upotrijebite ga za poravnanje obrisa s osima skice.", + "help.sketch.constraint.midpoint": "Postavlja točku na sredinu linije ili spaja središnje točke dviju linija. Upotrijebite ga za centriranje geometrije na bridu.", + "help.sketch.constraint.parallel": "Čini dvije linije paralelnima. Upotrijebite ga za stranice koje moraju ostati paralelne kad se druge kote promijene.", + "help.sketch.constraint.perpendicular": "Čini da se dvije linije susreću pod pravim kutom. Upotrijebite ga za pravokutne kutove između linija koje nisu na osima skice.", + "help.sketch.constraint.polygon": "Pretvara zatvoreni lanac linija u pravilni mnogokut: jednake stranice, a svaki vrh na istoj kružnici. Upotrijebite ga kako biste ručno nacrtani mnogokut učinili pravilnim i takvim ga zadržali.", + "help.sketch.constraint.symmetry": "Čini da dvije točke ili krivulje budu zrcalne jedna drugoj preko linije ili oko točke. Upotrijebite ga za održavanje simetričnosti profila kad mijenjate jednu stranu.", + "help.sketch.constraint.tangent": "Čini da se dvije krivulje, primjerice linija i luk, dodiruju tangentno. Upotrijebite ga za glatke prijelaze bez loma.", + "help.sketch.construction": "Prebacuje krivulje između obične i pomoćne geometrije, koju profili izostavljaju. Pomoćne krivulje upotrijebite kao pomagala za raspored, reference i kote.", "help.sketch.construction_line": "Izradite beskonačni pomoćni pravac kroz dvije točke. Upotrijebite ga kako biste rasporedili skicu bez razdvajanja profila.", "help.sketch.construction_line.angle": "Izradite beskonačni pomoćni pravac pod kutom od osi X skice. Odaberite točku i upišite kut ili odaberite smjer.", "help.sketch.construction_line.bisector": "Odaberite dvije ravne linije skice koje se sijeku, pa jednu od dviju simetrala kuta. Rezultat prati izvorne linije.", @@ -1412,11 +1564,71 @@ "help.sketch.construction_line.tangent_external": "Odaberite kružnicu ili kružni luk, pa vanjsku točku i rješenje tangente. Pregledi prikazuju samo dodirne točke na nacrtanom luku.", "help.sketch.construction_line.tangent_two": "Odaberite dvije kružnice ili kružna luka, pa u pregledu odaberite zajedničku tangentu. Dodirne točke moraju ležati na nacrtanim krivuljama.", "help.sketch.construction_line.vertical": "Izradite beskonačni okomiti pomoćni pravac kroz točku. Njegovo ograničenje okomitosti ostaje na snazi pri kasnijim promjenama.", + "help.sketch.copy": "Kopira odabrane krivulje i točke skice zajedno s ograničenjima i kotama među njima. Zalijepite ih u ovu ili u drugu skicu.", + "help.sketch.create": "Započinje novu 2D skicu na ravnini ili ravnom licu modela. Svaka naredba Ekstrudiraj, Rotiraj i Povlačenje počinje skicom.", + "help.sketch.cut": "Kopira odabranu geometriju skice i briše je iz skice, u jednom koraku poništavanja. Zalijepite je drugdje u ovoj skici ili u drugu skicu.", + "help.sketch.dimension": "Dodaje upravljačku kotu: duljinu, udaljenost, kut, polumjer ili promjer, ovisno o tome što odaberete. Upišite vrijednost ili naziv parametra, a skica će je slijediti.", + "help.sketch.edit": "Otvara postojeću skicu za uređivanje. Značajke izgrađene na njoj ažuriraju se kad dovršite skicu.", + "help.sketch.ellipse": "Crta elipsu iz njezina središta, kraja velike osi i točke na maloj strani. Upotrijebite ga za ovalne otvore, brijegove i druge izdužene okrugle oblike.", "help.sketch.equation_curve": "Crta krivulju prema formulama: x(t) i y(t) ili y = f(x), u zadanom rasponu. Formule mogu koristiti parametre i krivulja ih prati: upotrijebite je za sinusoidu, spiralu ili evolventu zuba zupčanika.", + "help.sketch.extend": "Produljuje liniju ili luk do najbliže krivulje na njegovu putu ili produljuje dvije krivulje dok se ne susretnu. Upotrijebite ga za zatvaranje razmaka u profilu nakon odsijecanja ili uređivanja.", + "help.sketch.fillet": "Zaobljuje kut na kojem se susreću dvije linije ili dva luka lukom zadanog polumjera. Više kutova zaobljenih zajedno dijeli jedan polumjer.", + "help.sketch.finish": "Napušta skicu i vraća se na model. Njezini zatvoreni profili tada su spremni za Ekstrudiraj, Rotiraj i druge značajke.", + "help.sketch.finish_auto_constrain": "Pokreće Automatska ograničenja na skici, a zatim je dovršava. Upotrijebite ga kako biste skicu ostavili potpuno ograničenom u jednom koraku.", + "help.sketch.fit_curves_to_section": "Presijeca mrežno ili puno tijelo ravninom skice i uklapa linije i lukove u presjek. Upotrijebite ga za ponovnu izradu skeniranog ili uvezenog dijela kao uredive skice.", + "help.sketch.include_3d": "Unosi bridove modela, vrhove i krivulje drugih skica u ovu skicu kao povezane reference, spljoštavajući one izvan njezine ravnine na nju. Upotrijebite ga za gradnju na geometriji koja ne leži u ravnini skice.", + "help.sketch.infinite": "Prebacuje linije između običnih linija i beskonačnih pomoćnih linija koje prolaze cijelim prikazom. Upotrijebite ih kao reference rasporeda koje nikad nisu prekratke.", + "help.sketch.insert_dxf": "Čita krivulje DXF crteža u skicu, skalirane, zakrenute i postavljene kako zadate. Upotrijebite ga za gradnju na 2D crtežu iz drugog programa, primjerice na obrisu za lasersko rezanje.", + "help.sketch.insert_svg": "Čita putanje SVG crteža u skicu, skalirane, zakrenute i postavljene kako zadate. Upotrijebite ga za pretvaranje logotipa ili vektorskog obrisa u krivulje koje možete ekstrudirati.", + "help.sketch.inspect": "Otvara izvješće o otvorenoj skici: koliko je stupnjeva slobode preostalo i je li potpuno ograničena. Upotrijebite ga kako biste saznali što se još pomiče prije dovršetka skice.", + "help.sketch.intersect": "Dodaje krivulje ili točke u kojima tijela, lica, bridovi ili druge skice presijecaju ravninu skice. Ostaju povezane i prate model kad se promijeni.", + "help.sketch.intersection_curve": "Izrađuje 3D krivulje na mjestu gdje se dva tijela ili lica susreću, povezane s njima. Upotrijebite ih kao putanje ili vodilice koje prate model.", + "help.sketch.isoparametric_curve": "Izrađuje 3D krivulju u smjeru U ili V lica kroz točku koju odaberete, povezanu s licem. Upotrijebite je kao putanju ili liniju razdvajanja koja prati plohu.", + "help.sketch.line": "Crta povezane linije točku po točku, kao otvoreni lanac ili zatvoreni obris. To je osnovni alat za većinu profila.", + "help.sketch.line.midpoint": "Crta liniju od njezine središnje točke do jednog kraja, a drugi kraj zrcali. Upotrijebite ga za linije centrirane na točku, primjerice simetričan brid.", + "help.sketch.line3d": "Crta povezane ravne linije između točaka u prostoru, uz uključenu 3D skicu. Upotrijebite ga za putanje i okvire koji izlaze iz ravnine skice.", + "help.sketch.mirror": "Zrcali krivulje i točke skice preko linije ili osi i dodaje ograničenja simetrije. Upotrijebite ga za crtanje polovice simetričnog profila i njegovo dovršavanje.", + "help.sketch.move": "Pomiče ili zakreće krivulje i točke skice, ili njihovu kopiju. Upotrijebite ga za premještanje geometrije uz zadržavanje njezina oblika i ograničenja.", + "help.sketch.offset": "Izrađuje kopiju lanca krivulja na zadanoj udaljenosti, s jedne ili obje strane. Upotrijebite ga za stijenke, obrise i zazore; udaljenost ostaje uređiva kota.", + "help.sketch.paste": "Lijepi geometriju skice koju ste kopirali ili izrezali u aktivnu skicu. Postavite je u točku ili je pomaknite za odmak.", + "help.sketch.pattern.circular": "Kopira geometriju skice oko središnje točke, po punom krugu ili zadanom kutu. Upotrijebite ga za kružne rasporede vijaka, žbice i zube.", + "help.sketch.pattern.rectangular": "Kopira geometriju skice u retke i stupce na zadanim razmacima. Upotrijebite ga za mreže provrta, utora ili otvora za ventilaciju.", + "help.sketch.point": "Postavlja točku skice. Upotrijebite je kao referencu za kote, ograničenja, središta provrta ili kasnije značajke.", + "help.sketch.point3d": "Postavlja točku u prostoru, uz uključenu 3D skicu. Upotrijebite je kao referencu za 3D krivulje i putanje.", + "help.sketch.polygon.circumscribed": "Crta pravilni mnogokut oko kružnice, veličine određene od središta do sredine stranice. Upotrijebite ga kad je poznata širina ključa, kao kod šesterokutne matice.", + "help.sketch.polygon.edge": "Crta pravilni mnogokut iz dvaju krajeva jedne stranice. Upotrijebite ga kad su poznate duljina i položaj stranice.", + "help.sketch.polygon.inscribed": "Crta pravilni mnogokut unutar kružnice, veličine određene od središta do vrha. Upotrijebite ga kad je poznata mjera preko vrhova.", + "help.sketch.project": "Unosi bridove modela, lica, vrhove i krivulje drugih skica u ovu skicu, projicirane na njezinu ravninu. Ostaju povezani i prate model kad se promijeni.", + "help.sketch.project_to_surface": "Projicira krivulje drugih skica duž normale ove skice na lice, kao povezane 3D krivulje. Upotrijebite ga za nanošenje logotipa, linije razdvajanja ili putanje na zakrivljenu plohu.", + "help.sketch.rectangle.center": "Crta pravokutnik iz njegova središta i jednog kuta. Upotrijebite ga za obrise centrirane na točku, primjerice ishodište.", + "help.sketch.rectangle.rounded": "Crta pravokutnik sa sva četiri kuta zaobljena istim polumjerom. Upotrijebite ga za ploče, poklopce i otvore sa zaobljenim kutovima.", + "help.sketch.rectangle.three_point": "Crta pravokutnik iz jednog brida i točke na suprotnoj strani. Upotrijebite ga za pravokutnike pod kutom u odnosu na osi skice.", + "help.sketch.rectangle.two_point": "Crta pravokutnik iz dvaju nasuprotnih kutova. Upotrijebite ga za obrise ploča, blokova i džepova.", + "help.sketch.scale": "Skalira krivulje i točke skice faktorom oko osnovne točke. Upotrijebite ga za promjenu veličine dijela skice bez ponovnog crtanja.", + "help.sketch.slot.arc_center": "Crta zakrivljeni utor zadane širine duž luka određenog njegovim središtem, početkom i krajem. Upotrijebite ga za utore za podešavanje oko okretišta.", + "help.sketch.slot.arc_three_point": "Crta zakrivljeni utor zadane širine duž luka kroz tri točke. Upotrijebite ga kad putanja utora mora prolaziti kroz zadane točke.", + "help.sketch.slot.center_point": "Crta ravni utor zadane širine od njegova središta do središta jednog završnog luka. Upotrijebite ga za utore centrirane na točku.", + "help.sketch.slot.center_to_center": "Crta ravni utor zadane širine između središta njegovih dvaju završnih lukova. Upotrijebite ga za utore kotirane od središta do središta, primjerice za utore za podešavanje vijaka.", + "help.sketch.slot.overall": "Crta ravni utor zadane širine od jednog vanjskog kraja do drugog. Upotrijebite ga kad je važna ukupna duljina utora.", + "help.sketch.spline.control_point": "Crta kubični spline oblikovan kontrolnim točkama kroz koje ne prolazi. Upotrijebite ga za glatke, tekuće krivulje koje oblikujete pomicanjem točaka.", + "help.sketch.spline.control_point_5": "Crta spline stupnja 5 oblikovan kontrolnim točkama. Upotrijebite ga tamo gdje se krivulje moraju spajati s glađom zakrivljenošću, primjerice na vidljivim oblikovanim plohama.", + "help.sketch.spline.fit_point": "Crta glatki spline koji prolazi kroz svaku točku koju postavite, otvoren ili zatvoren. Upotrijebite ga za ocrtavanje oblika kroz poznate točke.", + "help.sketch.spline3d": "Crta glatku krivulju u prostoru kroz točke, uz uključenu 3D skicu. Upotrijebite ga za putanje povlačenja i trase kabela koje izlaze iz ravnine skice.", + "help.sketch.spun_profile": "Izrađuje u ravnini skice obris koji tijelo opiše okretanjem oko osi. Upotrijebite ga za dobivanje siluete tokarenog dijela za Rotiraj ili za crtež.", + "help.sketch.text": "Piše tekst u skici odabranim fontom, visinom i kutom; njegove riječi mogu pratiti parametre. Ekstrudirajte ga ili primijenite Reljef za oznake i natpise.", + "help.sketch.transform": "Skalira, zakreće i pomiče geometriju skice oko središnje točke u jednom koraku. Upotrijebite ga za istodobno premještanje i promjenu veličine odabira.", + "help.sketch.trim": "Uklanja dio krivulje između najbližih sjecišta oko mjesta na koje kliknete. Upotrijebite ga za čišćenje linija koje se preklapaju u zatvoreni profil.", "help.solid.align": "Pomiče odabrana tijela tako da se točka, lice ili ravnina na njima poklopi s točkom, licem ili ravninom negdje drugdje. Upotrijebite ga kako biste postavili dio uz drugi bez mjerenja odmaka.", + "help.solid.boundary_fill": "Izrađuje puna tijela iz područja zatvorenih preklapajućim alatnim tijelima, ispunjavajući samo područja koja odaberete. Upotrijebite ga za izradu oblika iz prostora koji zajedno omeđuje više tijela.", + "help.solid.bounding_solid": "Izrađuje kutiju koja obuhvaća odabrana tijela, s neobaveznom marginom. Upotrijebite ga za uvid u ukupnu veličinu dijela ili kao sirovac iz kojeg se obrađuje.", + "help.solid.box": "Izrađuje kutiju iz duljine, širine i visine, iz kutne točke ili centriranu na točku. Upotrijebite ga za brzi blok, privremeni oblik ili tijelo u koje urezujete značajke.", "help.solid.chamfer": "Odsijeca odabrane bridove ravnim skošenjem zadane udaljenosti. Koristite ga kako biste ublažili oštre bridove ili olakšali uvođenje zatika ili vijka.", + "help.solid.coil": "Izrađuje zavojito tijelo, primjerice oprugu, iz promjera, broja zavoja, visine ili koraka te okruglog ili kvadratnog presjeka. Upotrijebite ga za opruge, zavojnice i zavojne grebene.", "help.solid.combine": "Spaja tijela, oduzima ih od ciljnog tijela ili zadržava samo zajednički volumen, a može zadržati i alatna tijela. Koristite ga kako biste spojili dijelove, jednim oblikom odrezali drugi ili pronašli gdje se dva tijela preklapaju.", + "help.solid.cone": "Izrađuje stožac iz osnovne kružnice i visine, ili krnji stožac kad i vrh ima polumjer. Upotrijebite ga za suženja, lijevke, upuštenja i šiljke.", + "help.solid.cylinder": "Izrađuje valjak iz polumjera ili promjera i visine, koji stoji na točki duž osi. Upotrijebite ga za osovine, zatike, ispuste i okrugle pripremke.", "help.solid.draft": "Naginje odabrana lica za kut koji zadate, mjereno od neutralne ravnine. Upotrijebite ga na lijevanim ili brizganim dijelovima kako bi izašli iz kalupa.", + "help.solid.emboss": "Izdiže profile skice, primjerice tekst ili logotip, iz lica ili ih utiskuje u njega. Upotrijebite ga za natpise, oznake i markiranje na ravnim ili okruglim licima.", "help.solid.extrude": "Izvlači profil skice u 3D duž zadane udaljenosti, dodajući ili uklanjajući materijal. Koristite ga kako biste ravni obris pretvorili u blok, ispust ili džep.", "help.solid.fillet": "Zaobljuje odabrane bridove glatkim kružnim prijelazom zadanog polumjera. Koristite ga kako biste omekšali oštre bridove radi čvrstoće, udobnosti ili izgleda.", "help.solid.hole": "Buši okrugli provrt zadanog promjera na odabranoj točki lica. Koristite ga za prolaze vijaka, zatike i druge jednostavne okrugle otvore.", @@ -1425,20 +1637,60 @@ "help.solid.move": "Pomiče ili rotira odabrana tijela, ili njihovu kopiju, za udaljenost i kut koje zadate. Upotrijebite ga kako biste premjestili tijelo ili izradili drugo na drugom mjestu.", "help.solid.offset_face": "Gura odabrana lica unutra ili van za udaljenost koju zadate, a okolna lica ih prate. Upotrijebite ga kako biste podebljali stijenku, produbili džep ili povisili ispupčenje bez uređivanja skice.", "help.solid.pattern.circular": "Ponavlja odabrane objekte oko osi, za zadani broj i ukupni kut. Koristite ga za kružne rasporede vijaka, žbice, rebra hladnjaka i zupce.", + "help.solid.pattern.path": "Kopira značajke ili tijela duž putanje skice, prema broju i razmaku. Upotrijebite ga za provrte duž zakrivljenog brida ili dijelove duž vodilice.", "help.solid.pattern.rectangular": "Ponavlja odabrane objekte u jednom ili dva smjera sa zadanim brojem i razmakom. Koristite ga za redove i mreže provrta, rebara, zatika ili nožica.", + "help.solid.pipe": "Izrađuje okruglu cijev duž putanje krivulja skice, punu ili šuplju s debljinom stijenke. Upotrijebite ga za cijevi, žice, kabele i vodilice.", "help.solid.press_pull": "Dodaje ili uklanja materijal dok povlačite lice, ili zaobljuje bridove dok ih povlačite. Upotrijebite ga za brze izmjene izravno na modelu.", "help.solid.remove": "Uklanja odabrana tijela iz dizajna od ove točke vremenske trake nadalje; raniji koraci ih zadržavaju. Upotrijebite ga kako biste odbacili pomoćno tijelo, npr. alat za rezanje koji vam više ne treba.", "help.solid.replace_face": "Premješta odabrana ravna lica na ravninu ili na paralelno lice, a tijelo raste ili se smanjuje da ga dosegne. Upotrijebite ga kako biste poravnali lice s drugim dijelom ili pomoćnom ravninom.", "help.solid.revolve": "Okreće profil skice oko osi i izrađuje okruglo tijelo. Koristite ga za vratila, gumbe, posude i svaki dio koji je okrugao oko središnje linije.", + "help.solid.rib": "Dodaje tanku stijenku duž otvorene krivulje skice, produljenu do sljedećih lica tijela. Upotrijebite ga za ukrućivanje stijenki i kutova, primjerice u kalupljenim ili ispisanim dijelovima.", "help.solid.scale": "Povećava ili smanjuje odabrana tijela za faktor, jednako u svim smjerovima ili posebno po osi. Upotrijebite ga kako biste promijenili veličinu uvezenog oblika ili uračunali skupljanje.", "help.solid.shell": "Izdubljuje tijelo i ostavlja stijenke zadane debljine, a može otvoriti lica koja odaberete. Koristite ga za kutije, poklopce i druge tankostijene dijelove.", + "help.solid.sphere": "Izrađuje kuglu iz polumjera ili promjera oko središnje točke. Upotrijebite ga za kugle, gumbe i zaobljene krajeve.", "help.solid.split_body": "Reže tijelo na zasebna tijela ravninom, licem drugog tijela ili linijom skice. Upotrijebite ga za dijelove koji se zasebno ispisuju, obrađuju ili boje.", "help.solid.split_face": "Dijeli odabrana lica po ravnini, licu drugog tijela ili krivuljama skice, bez rezanja tijela. Upotrijebite ga kako biste dijelu lica dali vlastiti nagib, boju ili odmak.", "help.solid.sweep": "Povlači profil skice duž putanje i izrađuje tijelo. Koristite ga za cijevi, tračnice, ručke i svaki dio konstantnog presjeka koji prati krivulju.", + "help.solid.thread": "Dodaje ISO metrički navoj na cilindrično lice, kao kozmetički navoj ili urezan u tijelo. Upotrijebite ga za vijke, navojne provrte i ispisane dijelove s vijkom.", + "help.solid.torus": "Izrađuje prsten iz velikog polumjera oko njegove osi i malog polumjera za njegov okrugli presjek. Upotrijebite ga za O-prstenove, ručke i okrugle perle.", + "help.solid.web": "Dodaje tanku stijenku duž svake otvorene krivulje skice, svaku produljenu do sljedećih lica tijela. Upotrijebite ga za mrežu potpornih stijenki unutar kućišta i posuda.", + "help.surface.blend": "Izrađuje plohu koja premošćuje bridove dvaju tijela, tangentnu ili kontinuiranu po zakrivljenosti s oba. Upotrijebite ga za glatko spajanje dviju ploha preko razmaka.", + "help.surface.boundary": "Izrađuje plohu koja ispunjava petlju od dvije do četiri krivulje skice ili mrežu krivulja. Upotrijebite ga za ploče slobodnog oblika određene svojim bridovima.", + "help.surface.extend": "Produljuje plohu preko njezinih bridova, za zadanu udaljenost ili do ravnine ili lica. Upotrijebite ga kako bi se plohe preklapale prije odsijecanja ili sašivanja.", + "help.surface.extrude": "Potiskuje krivulje ili profile skice na zadanu udaljenost u plohu, samo stijenke, bez zatvaranja krajeva. Upotrijebite ga za tanke stijenke te alate za rezanje ili razdvajanje.", + "help.surface.fair": "Zaglađuje unutrašnjost ploha slobodnog oblika dok njihovi bridovi ostaju na mjestu. Upotrijebite ga za izravnavanje izbočina i valovitih odraza na plohi.", + "help.surface.loft": "Izrađuje plohu koja redom prolazi kroz dva ili više presjeka, bez zatvaranja krajeva. Upotrijebite ga za ljuske i prijelaze između različitih oblika.", + "help.surface.match": "Ponovno izrađuje brid plohe tako da se susreće s bridom drugog tijela, uz usklađenu tangentnost ili zakrivljenost. Upotrijebite ga za glatko zatvaranje šava između dviju ploha.", + "help.surface.offset": "Izrađuje kopiju lica na udaljenosti duž njihovih normala, kao novo plošno tijelo. Upotrijebite ga za oplate, zazore ili drugu stranu stijenke.", + "help.surface.patch": "Ispunjava područje skice ili zatvorenu petlju bridova licem, ravnim ili glatko zakrivljenim. Upotrijebite ga za zatvaranje otvora u plošnom tijelu.", + "help.surface.reverse_normal": "Okreće lica plošnog tijela, pa im se vanjska strana okrene na drugu stranu. Upotrijebite ga prije podebljavanja ili sašivanja kad lica gledaju prema unutra.", + "help.surface.revolve": "Okreće krivulje ili profile skice oko osi u plohu, bez zatvaranja krajeva. Upotrijebite ga za okrugle ljuske poput sjenila svjetiljki, zdjela i mlaznica.", + "help.surface.ruled": "Izrađuje traku ravnih linija koje izlaze iz lanca bridova, duž normale lica, tangente ili pod kutom. Upotrijebite ga za stijenke s nagibom, izlazne plohe i plohe razdvajanja.", + "help.surface.stitch": "Sašiva plošna tijela duž njihovih zajedničkih bridova, u puno tijelo kad se zatvore. Upotrijebite ga za pretvaranje skupa ploha u jedno tijelo.", + "help.surface.sweep": "Pomiče krivulje ili profile skice duž putanje u plohu, bez zatvaranja krajeva. Upotrijebite ga za oluke, obrube i druge tanke oblike koji prate putanju.", + "help.surface.thicken": "Daje plošnom tijelu debljinu i pretvara ga u puno tijelo. Upotrijebite ga za izradu dijelova za ispis ili obradu iz ploha.", + "help.surface.trim": "Odsijeca plohu ravninom, drugom plohom ili krivuljama skice i zadržava stranu koju odaberete. Upotrijebite ga za rezanje ploha na oblik prije sašivanja.", + "help.surface.unstitch": "Odvaja lica tijela u zasebna plošna tijela; puno tijelo se raspada na plohe. Upotrijebite ga za preradu pojedinačnih lica tijela.", + "help.surface.untrim": "Uklanja otvore iz lica ili vraća njegovu vanjsku granicu na prirodne granice plohe. Upotrijebite ga za povrat cijele plohe nakon odsijecanja.", "help.third_party": "Licence trećih strana", "help.timeline.compute_all": "Ponovno gradi svaku značajku na vremenskoj traci od početka. Upotrijebite ga kako biste osvježili model i vidjeli koje značajke ne uspijevaju.", "help.timeline.delete": "Briše odabrane značajke s vremenske trake; brisanje skice briše i značajke izrađene iz nje. Upotrijebite ga kako biste uklonili korak koji više ne želite.", + "help.timeline.dependents": "Navodi značajke koje bi se izbrisale zajedno s nekom značajkom i one koje bi bez nje zakazale. Upotrijebite ga prije brisanja ili promjene značajke.", "help.timeline.play": "Ponavlja preostale značajke jednu po jednu, s pauzom od pola sekunde nakon svake ponovne izgradnje. Ponovno pokrenite ovu naredbu ili pritisnite Escape za zaustavljanje; Idi na kraj skače odmah.", + "help.view.apply": "Okreće prikaz u standardni smjer, primjerice pogled sprijeda, odozgo ili izometrijski, ili u spremljeni imenovani prikaz. Upotrijebite ga za gledanje modela iz stalnog kuta.", + "help.view.delete": "Briše spremljeni imenovani prikaz. Model se ne mijenja.", + "help.view.fit": "Zumira i pomiče prikaz tako da cijeli model, ili odabir, ispuni prikaz. Upotrijebite ga kad je model izašao iz vidnog polja.", + "help.view.grid": "Prikazuje ili skriva mrežu u prozoru prikaza. Upotrijebite je kao vodič za veličinu i orijentaciju pri modeliranju.", + "help.view.ground_shadow": "Prikazuje ili skriva sjenu modela na podlozi. Upotrijebite je kako biste lakše procijenili visinu i položaj.", + "help.view.home": "Vraća prikaz u početni smjer i uklapa model. Upotrijebite ga za brz povratak na poznat prikaz.", + "help.view.list": "Navodi imenovane prikaze spremljene u dizajnu. Upotrijebite ga za pronalaženje prikaza koji ste ranije spremili.", + "help.view.navigate": "Okreće, pomiče ili zumira prikaz u koracima, primjerice za četvrtinu okreta ulijevo. Upotrijebite ga za precizno pomicanje kamere umjesto povlačenja.", + "help.view.navigation_bar": "Prikazuje ili skriva navigacijsku traku na dnu prozora prikaza. Sakrijte je za više prostora, prikažite je za pristup alatima prikaza mišem.", + "help.view.rename": "Daje spremljenom imenovanom prikazu novi naziv. Sam se prikaz ne mijenja.", + "help.view.save": "Sprema trenutni položaj kamere kao imenovani prikaz u dizajnu. Upotrijebite ga za kasniji povratak na isti kut, primjerice za snimke zaslona.", + "help.view.spin": "Neprekidno orbitira prikaz oko modela do sljedećeg klika ili tipke. Upotrijebite ga za predstavljanje modela sa svih strana.", + "help.view.tool": "Uključuje alat kamere, orbitiranje, pomicanje ili zumiranje, pa povlačenje lijevom tipkom miša pomiče prikaz dok ga ne isključite. Upotrijebite ga na dodirnoj pločici ili bez srednje tipke miša.", + "help.view.visual_style": "Mijenja način crtanja modela: osjenčano sa ili bez bridova, žičani model ili sa skrivenim bridovima. Žičani model upotrijebite za pogled unutra, osjenčano za čist izgled.", "history.sketch_under_constrained": "Nedovoljno ograničeno (stupnjevi slobode: {count})", "history.sketch_under_constrained_one": "Nedovoljno ograničeno (stupanj slobode: {count})", "hole_presets.confirm_delete": "Potvrdi brisanje", @@ -1615,6 +1867,10 @@ "material.selection.mixed": "Različiti materijali", "measure.at": "Na", "measure.item": "Stavka {number}", + "measure.quick_copied": "Mjerenje kopirano", + "measure.quick_copy": "Kliknite za kopiranje mjerenja", + "measure.quick_edges": "Bridovi: {count}", + "measure.quick_faces": "Lica: {count}", "messages.ago_h": "prije {n} h", "messages.ago_min": "prije {n} min", "messages.ago_s": "prije {n} s", @@ -1767,6 +2023,11 @@ "not a move": "nije pomak", "not a number": "nije broj", "open a sketch first": "najprije otvorite skicu", + "parameters.apply_live": "Primijeni svaku promjenu čim napustite njezino polje", + "parameters.apply_live_tip": "Isključeno: promjene čekaju Primijeni ili U redu, a zatim se dizajn jednom ponovno izgradi za sve njih. Odustani ili Escape ih odbacuje.", + "parameters.held": "Promjene koje čekaju Primijeni: {count}", + "parameters.held_hint": "Uređujte izraze i jedinice. Promjene čekaju Primijeni ili U redu.", + "parameters.held_one": "1 promjena čeka Primijeni", "parts.automatic_name": "automatski", "parts.dimension.across_flats": "Mjera ključa", "parts.dimension.bore": "Provrt", @@ -1789,6 +2050,8 @@ "parts.family.set_screw": "Bezglavi vijak s unutarnjim šesterokutom, ravni vrh", "parts.family.socket_head_cap_screw": "Vijak s cilindričnom glavom i unutarnjim šesterokutom", "parts.family.washer": "Obična podloška", + "parts.holes": "Provrti (neobavezno)", + "parts.placement_hint": "odaberite jedan ili više provrta kako biste u svaki smjestili dio, ili U redu postavlja dio u ishodište", "pattern.alignment_2": "Poravnanje 2", "pattern.alignment_hint": "Broj uključuje izvornik. Središte ravnomjerno raspoređuje kopije; Obrni bira dodatnu stranu za parne ukupne brojeve.", "pattern.direction_1": "Smjer 1", @@ -1955,6 +2218,8 @@ "prefs.origin_plane_names": "Nazivi ravnina ishodišta", "prefs.origin_plane_names_note": "Nazivi prikazani u stablu modela, dijalozima i popisima. {top} je XY, {front} je XZ, a {right} je YZ, sa Z prema gore. Naredbe, skripte i spremljeni dizajni zadržavaju XY, XZ i YZ.", "prefs.overall_speed": "Ukupna brzina", + "prefs.parameter_list_rows": "Retci popisa parametara", + "prefs.parameter_list_rows_tip": "Strelica prema dolje u praznom polju vrijednosti prikazuje popis parametara, prvo favorite, do ovoliko njih. Manje održava popis laganim u velikom dizajnu.", "prefs.persistent_ribbon_pressed": "Istakni aktivne naredbe na vrpci", "prefs.persistent_ribbon_pressed_help": "Ističe naredbe na vrpci dok su njihovi efekti uključeni. Isključite kako biste zadržali prethodni neutralni izgled i pomoć za naredbe.", "prefs.perspective_fov": "Vidno polje", @@ -1984,6 +2249,8 @@ "prefs.quality_fastest": "najbrže", "prefs.quality_slowest": "najsporije", "prefs.quality_smoother": "glađe", + "prefs.quick_measure": "Brzo mjerenje u kutu prikaza", + "prefs.quick_measure_note": "Veličina odabira u donjem desnom kutu prikaza: duljina brida, ili Ø i R za okrugli, površina lica, položaj točke, udaljenost i kut dviju stavki, zbroj više bridova ili lica. Klik ga kopira.", "prefs.render_previous_geometry_budget": "Koristi prethodni proračun geometrije za pregled renderiranja", "prefs.render_previous_geometry_budget_help": "Vrati prethodnu fiksnu granicu geometrije (512 MiB na 64-bitnom desktopu, 128 MiB drugdje) umjesto proračuna prema uređaju.", "prefs.reports_saved": "Spremljeno: {n}", @@ -2029,6 +2296,8 @@ "prefs.sketch_tab_always_note": "Kartica Skica ostaje na vrpci, odmah iza kartice Model, dok nijedna skica nije otvorena. Njezini alati za crtanje tada pokreću skicu na odabranoj ravnini ili licu, ili na ravnini koju sljedeću odaberete, s alatom spremnim u njoj.", "prefs.sketch_tracking_guides": "Vodilice poravnanja središta i središnje točke", "prefs.sketch_tracking_guides_note": "Zadržite pokazivač iznad središta kružnice ili središnje točke ravnog brida, zatim se pomaknite vodoravno ili okomito da se prikaže privremena točkasta vodilica.", + "prefs.sketch_tracking_points": "Vodilice poravnanja iz krajnjih točaka i kutova tijekom crtanja", + "prefs.sketch_tracking_points_note": "Dok alat za crtanje čeka točku, zadržavanje pokazivača iznad kraja linije ili luka, kuta pravokutnika ili slobodne točke prikazuje istu privremenu točkastu vodilicu kao i za središte ili središnju točku. Isključeno: samo središta i središnje točke.", "prefs.sketching": "Skiciranje", "prefs.snap_drawing_note": "Tijekom crtanja ili povlačenja točke skice.", "prefs.source": "Izvor", @@ -2056,6 +2325,16 @@ "prefs.theme_follow_off": "Isključeno", "prefs.theme_follow_stop": "Prestani pratiti", "prefs.theme_import_hint": "Kliknite za odabir datoteke teme, ispustite je ovdje ili pritisnite Ctrl+V (Cmd+V na Macu) s kopiranom temom", + "prefs.theme_option_opacity": "Neprozirnost", + "prefs.theme_option_opacity_help": "Podešava neprozirnost plutajućih panela. Donja granica čitljivosti štiti tekst preko boja prozora prikaza.", + "prefs.theme_option_tab_colours": "Boje kartica", + "prefs.theme_option_tab_colours_help": "Koristi boje panela i prozora prikaza ove teme za svaku karticu vrpce.", + "prefs.theme_option_tab_fade_ms": "Prijelaz kartica", + "prefs.theme_option_tab_fade_ms_help": "Postupno prelijeva boje panela i prozora prikaza pri promjeni kartice. Nula mijenja trenutno.", + "prefs.theme_options_badge": "Opcije", + "prefs.theme_options_more": "Više · {count}", + "prefs.theme_options_reset": "Vrati zadano", + "prefs.theme_options_unavailable": "Nedostupno na ovom uređaju", "prefs.theme_paste": "Zalijepi temu", "prefs.theme_paste_none": "Međuspremnik ne sadrži SolidCraft temu", "prefs.timeline_half_hour": "−30 min", @@ -2077,6 +2356,8 @@ "prefs.under_cursor": "Ispod pokazivača", "prefs.units_precision": "Jedinice i preciznost", "prefs.updates": "Ažuriranja", + "prefs.value_boxes_in_view": "Prikaži vrijednosti pokraj njihovih ručica u prikazu", + "prefs.value_boxes_in_view_tip": "Vrijednost naredbe (Udaljenost, Polumjer…) u okviru pokraj njezine ručice, preko modela. Isključeno: upisujete je u prozor naredbe, koji preuzima tipkovnicu nakon odabira, i ništa ne prekriva model.", "prefs.view": "Ponašanje prikaza", "prefs.view_confirm_corner": "U redu i Odustani u kutu prikaza", "prefs.view_confirm_corner_note": "Dok je otvoren dijalog ili skica, kvačica i križić gore desno u prikazu rade isto što i gumbi U redu i Odustani u dijalogu, odnosno završavaju i poništavaju skicu. Navigator prikaza pomiče se ispod njih.", @@ -2086,6 +2367,12 @@ "prefs.viewport_background": "Pozadina", "prefs.viewport_background_colour": "Boja", "prefs.viewport_background_theme": "Gradijent teme", + "prefs.viewport_section_status": "Prikaži status presjeka u prozoru prikaza", + "prefs.viewport_section_status_help": "Prikazuje postavku presjeka u prozoru prikaza. Escape i aktivni gumb na vrpci isključuju je. Isključite kako biste zadržali prethodne kontrole presjeka.", + "prefs.viewport_status_details": "Detalji prozora prikaza uživo", + "prefs.viewport_status_details_help": "Uređuje postavke aktivne analize i presjeka u standardnim karticama detalja. Isključite kako biste vratili legende analize samo za čitanje i dijaloški prozor Presjek.", + "prefs.viewport_status_summary": "Sažmi statusne stavke prozora prikaza", + "prefs.viewport_status_summary_help": "Sažima tri ili više statusnih stavki prozora prikaza u jednu zbirnu oznaku. Isključite kako bi svaka stavka ostala u punom retku.", "prefs.wheel": "Kotačić", "prefs.wheel_trackpad": "Kotačić i dodirna pločica", "prefs.workspace": "Radni prostor", @@ -2964,7 +3251,9 @@ "render.output_ready": "Spremno za renderiranje", "render.output_run_check": "Pokreni provjeru", "render.output_size_limit": "{method}: do {dimension} px po strani i {pixels} piksela. Najveća veličina koja stane: {width} × {height}.", + "render.output_size_method_profile": "Za renderiranje {width} × {height} odaberite {method} s CPU profilom resursa {profile} ili manju veličinu.", "render.output_size_ok": "Veličina u redu · {width} × {height} px", + "render.output_size_profile": "Za renderiranje {width} × {height} odaberite CPU profil resursa {profile} ili manju veličinu.", "render.output_swap_orientation": "Zamijeni širinu i visinu", "render.output_tools_inspector": "Veličinu i format uredite u inspektoru Izlaz.", "render.output_view_image": "Prikaži sliku", @@ -3177,6 +3466,7 @@ "render.size_custom": "Prilagođeno…", "render.size_full_hd": "Full HD", "render.size_hd": "HD", + "render.size_needs_profile": "{size} · treba {profile}", "render.size_presets": "Unaprijed zadane veličine", "render.size_presets_pending": "Stiže s uređivačem Izlaz", "render.size_preview": "Pregled", @@ -3464,6 +3754,24 @@ "sheet.flange_type_contour": "Kontura", "sheet.flange_type_edge": "Brid", "sheet.no_body": "nema limenog tijela: izradite ga pomoću Prirubnica > Osnova ili pretvorite ploču u lim pomoću Pretvori u lim (odaberite njezino veliko ravno lice)", + "sheet.personal_rules.confirm_delete": "Potvrdi brisanje", + "sheet.personal_rules.delete": "Izbriši", + "sheet.personal_rules.delete_prompt": "Izbrisati ovo osobno pravilo? Pravila koja su već u dizajnima ostaju nepromijenjena.", + "sheet.personal_rules.full": "Osobna biblioteka već sadrži 64 pravila.", + "sheet.personal_rules.invalid_name": "Unesite kratak naziv bez prijeloma redaka ili kontrolnih znakova.", + "sheet.personal_rules.invalid_values": "Dovršite vrijednosti pravila: svaka se mora moći izračunati u ovom dizajnu, uz pozitivnu debljinu i K-faktor od 0 do 1.", + "sheet.personal_rules.missing": "Odabrano osobno pravilo više nije dostupno.", + "sheet.personal_rules.name": "Naziv pravila", + "sheet.personal_rules.name_exists": "Osobno pravilo s ovim nazivom već postoji. Upotrijebite drugi naziv.", + "sheet.personal_rules.name_hint": "Jedinstven naziv", + "sheet.personal_rules.overwrite": "Spremi promjene u odabrano osobno pravilo", + "sheet.personal_rules.rename": "Preimenuj", + "sheet.personal_rules.rule": "Osobno pravilo", + "sheet.personal_rules.save": "Spremi novo", + "sheet.personal_rules.scope": "Spremljeno u vašem profilu za sve dizajne.", + "sheet.personal_rules.select": "Odaberite pravilo", + "sheet.personal_rules.title": "Osobna pravila", + "sheet.personal_rules.unsupported": "Ovo pravilo ima vrijednosti koje ova verzija ne može upotrijebiti.", "shortcut.json_error": "parametri moraju biti JSON: {error}", "shortcut.no_command": "nema naredbe `{id}`; tekst prije JSON-a mora biti id naredbe", "shortcut.pin": "Prikvači na vrh okvira S", @@ -3500,6 +3808,7 @@ "sketch_text.align_right": "Desno", "sketch_text.bold": "Podebljano", "sketch_text.builtin_font": "{family} (ugrađeno)", + "sketch_text.formula": "Počnite znakom = kako biste riječi izgradili iz parametara: ='W' + number(Width)", "sketch_text.italic": "Kurziv", "sketch_text.loading_fonts": "Traženje instaliranih fontova…", "sketch_text.on_path": "Tekst prati svoju putanju: krivulja ga postavlja", @@ -3640,6 +3949,8 @@ "ui.3d_mouse_section": "3D miš", "ui.3d_mouse_show_in_view": "Prikaži 3D miš u 3D prikazu", "ui.3d_mouse_show_in_view_tip": "Slika vašeg 3D miša u donjem lijevom kutu, koja se kreće dok ga pomičete.", + "ui.3d_mouse_sketch_orbit": "Okreći skice 3D mišem", + "ui.3d_mouse_sketch_orbit_tip": "3D miš okreće prikaz i dok je skica otvorena i gleda se okomito, kao što to može i miš. Isključeno: pomiče i zumira skicu dok ne nagnete prikaz.", "ui.3d_mouse_skip": "Moj uređaj ovo ne može", "ui.3d_mouse_skip_tip": "Preskače ovo kretanje u oba smjera, a kalibracija ga izostavlja.", "ui.3d_mouse_speed": "Brzina 3D miša", @@ -3708,6 +4019,7 @@ "ui.agent_chat_auth_terminal": "Pokrenite ovo u terminalu, a zatim pritisnite Poveži ponovno:", "ui.agent_chat_auth_title": "Prijavi se kod agenta", "ui.agent_chat_cancel": "Odustani", + "ui.agent_chat_cannot_start": "Instalirano, ali se ne pokreće: {error}", "ui.agent_chat_changes": "Promjene: {n}, jedan korak poništavanja", "ui.agent_chat_changes_n": "Promjene: {n}", "ui.agent_chat_changes_of": "Promjene: {n}/{limit}", @@ -3800,9 +4112,12 @@ "ui.agent_chat_queued_stopped": "U redu čekanja (zaustavljeno, nije poslano)", "ui.agent_chat_refresh": "Osvježi", "ui.agent_chat_refused": "Nije dopušteno agentu: {reason}", + "ui.agent_chat_reinstall": "{agent} je instaliran ({program}), ali se nije pokrenuo kad ga je SolidCraft provjerio: {error}\nInstalacija je možda nepotpuna ili izrađena za drugu vrstu računala. Ponovno ga instalirajte naredbom `{install}`, provjerite ispisuje li u terminalu svoju verziju, a zatim pritisnite Osvježi.", "ui.agent_chat_remove": "Ukloni iz reda", "ui.agent_chat_remove_file": "Skini s ove poruke", "ui.agent_chat_remove_imported": "Skini s ove poruke; dio ostaje u dizajnu (Poništi ga uklanja)", + "ui.agent_chat_reply_cut_off": "Odgovor je dosegnuo ograničenje duljine prije nego što je završio pa je možda nepotpun. Zamolite agenta da nastavi ili da odgovori kraće.", + "ui.agent_chat_reply_declined": "Model je odbio odgovoriti na ovu poruku.", "ui.agent_chat_retry": "Pokušaj ponovno", "ui.agent_chat_right": "Desno", "ui.agent_chat_save": "Spremi", @@ -3861,12 +4176,17 @@ "ui.ai_copy_suffix": "kopiraj", "ui.ai_custom_url": "Prilagođeni URL", "ui.ai_default": "Zadano", + "ui.ai_edit": "Uredi", + "ui.ai_edit_agent_title": "Uredi agenta", + "ui.ai_edit_gone": "Ovaj agent je uklonjen pa nema što spremiti.", + "ui.ai_edit_tip": "Promijenite naziv, naredbu ili krajnju točku ovog agenta. Spremljeni ključ ostaje skriven.", "ui.ai_http_busy": "krajnja točka je zauzeta ili je preko ograničenja; pokušajte ponovno kasnije", "ui.ai_http_key": "krajnja točka odbila je ključ: provjerite ga u Postavke > AI", "ui.ai_http_not_found": "nema takve krajnje točke ili modela: provjerite osnovni URL i naziv modela", "ui.ai_http_too_large": "razgovor je prevelik za ovu krajnju točku; započnite novi razgovor", "ui.ai_key": "API ključ", "ui.ai_key_hint": "neobavezno; čuva se u sustavskom privjesku ključeva", + "ui.ai_key_keep_hint": "ostavite prazno kako biste zadržali spremljeni ključ", "ui.ai_key_needs_https": "Ključ se šalje samo preko HTTPS-a ili na ovo računalo.", "ui.ai_key_note": "Čuva se u sustavskom privjesku ključeva, nikad u postavkama SolidCrafta. Za lokalni poslužitelj ostavite prazno.", "ui.ai_keychain_error": "Sustavski privjesak ključeva odbio je: {error}", @@ -3898,6 +4218,7 @@ "ui.ai_request_timeout": "Vremensko ograničenje zahtjeva", "ui.ai_request_timeout_hint": "Koliko dugo jedan zahtjev modelu na ovoj krajnjoj točki smije trajati, od 30 s do jednog sata. Povećajte za spori lokalni model.", "ui.ai_reset": "Vrati zadano", + "ui.ai_save": "Spremi", "ui.ai_settings": "AI postavke…", "ui.ai_show_features": "Prikaži AI funkcije", "ui.ai_show_features_note": "Razgovor s agentom: ploča u kojoj agent za kodiranje koji već koristite ili krajnja točka modela koju odaberete radi na otvorenom dizajnu. Isključeno: nema AI gumba, prečaca, stavke izbornika ni ploče, i ništa se ne traži niti pokreće.", @@ -4071,6 +4392,40 @@ "ui.design_properties.project": "Projekt", "ui.design_properties.revision": "Revizija", "ui.design_saved": "Spremljeno: {path}", + "ui.details_access_caption": "Lica pokazuju može li alat doći do njih iz odabranog smjera.", + "ui.details_angle": "Kut", + "ui.details_continuity_caption": "Uredite odabrani šav. Ostali šavovi zadržavaju vlastite postavke.", + "ui.details_continuity_stale": "Ranija geometrija: ponovno pokrenite analizu kako biste uredili ili uokvirili ovaj šav.", + "ui.details_copy_list": "Kopiraj popis", + "ui.details_curvature": "Tolerancija zakrivljenosti", + "ui.details_curvature_caption": "Lica su obojena prema svojem lokalnom polumjeru zakrivljenosti.", + "ui.details_direction": "Smjer alata", + "ui.details_draft_caption": "Lica su obojena prema kutu u odnosu na smjer vađenja iz kalupa.", + "ui.details_edit_seam": "Uredi šav", + "ui.details_fixture": "Uzorak komponente — samo primjer podataka", + "ui.details_fixture_caption": "Uzorak komponente s ilustrativnim vrijednostima; ovo nije rezultat modela.", + "ui.details_fixture_problem": "Primjer nalaza s problemom", + "ui.details_fixture_readout": "Primjer izmjerene vrijednosti", + "ui.details_flip": "Obrni", + "ui.details_gap": "Otvoreni kraj", + "ui.details_hatch": "Šrafiraj presječena lica", + "ui.details_jump": "Skok zakrivljenosti", + "ui.details_key": "Legenda", + "ui.details_offset": "Odmak", + "ui.details_plane": "Ravnina", + "ui.details_pull": "Smjer vađenja iz kalupa", + "ui.details_radius": "Polumjer", + "ui.details_readout": "Očitanje", + "ui.details_samples": "Uzorci", + "ui.details_seam": "{body} · Brid {edge}", + "ui.details_settings": "Postavke", + "ui.details_sharp_gap": "Oštro ili otvoreno", + "ui.details_show_all": "Prikaži sve ({count})", + "ui.details_smooth": "Glatko", + "ui.details_stripes": "Pruge", + "ui.details_target": "Uokviri geometriju", + "ui.details_worst_seams": "Najgori šavovi", + "ui.details_zebra_caption": "Odražene pruge pokazuju koliko se glatko lica spajaju.", "ui.diameter": "Promjer", "ui.dimension": "Kota", "ui.direct_interaction": "Izravna interakcija", @@ -4131,6 +4486,7 @@ "ui.equation_y_of_x": "y(x)", "ui.equivalent_radius": "Ekvivalentni polumjer", "ui.expand": "Proširi", + "ui.explode_needs_two_components": "Rastavljeni prikaz razmiče komponente, pa dizajn treba imati dvije ili više njih (Nova komponenta, Umetni dizajn ili Izradi komponente iz tijela)", "ui.exportDxf": "Izvezi DXF…", "ui.export_ellipsis_keymap": "Izvezi…", "ui.export_keymap_tip": "Tipke, favoriti okvira S i alatna traka, kao datoteka", @@ -4419,12 +4775,17 @@ "ui.plugins_skipped": "Mape nije bilo moguće pročitati: {count}.", "ui.plugins_version": "{version}", "ui.point": "Točka", + "ui.point_cloud_add_filter": "Dodaj filter…", "ui.point_cloud_auto_range": "Automatski raspon", "ui.point_cloud_color_by": "Boja prema", "ui.point_cloud_file_colors": "Boje iz datoteke", + "ui.point_cloud_filter_by": "Filtriraj prema", "ui.point_cloud_height_high": "Najviše", "ui.point_cloud_height_low": "Najniže", + "ui.point_cloud_legend_filter": "Prikazano i skriveno u legendi iznad", "ui.point_cloud_no_file_colors": "Datoteka nema boja", + "ui.point_cloud_pause": "Pauziraj", + "ui.point_cloud_play": "Reproduciraj", "ui.point_cloud_single_color": "Jedna boja", "ui.point_cloud_size": "Veličina točke", "ui.point_cloud_units": "Jedinice datoteke", @@ -4453,6 +4814,7 @@ "ui.properties.inertia_y": "Iyx, Iyy, Iyz", "ui.properties.inertia_z": "Izx, Izy, Izz", "ui.properties.measuring": "Mjerenje…", + "ui.properties.measuring_bodies": "Mjerenje… {done} od {total} tijela", "ui.properties.measuring_failed": "Mjerenje je zaustavljeno prije završetka.", "ui.properties.principal_axis": "Glavna os {number}", "ui.properties.principal_moments": "Glavni momenti tromosti", @@ -4520,6 +4882,7 @@ "ui.selectOther": "Odaberi drugo…", "ui.selectTangentChain": "Odaberi tangentni lanac", "ui.select_all": "Odaberi sve", + "ui.select_components_by_face": "Odaberi komponente (lice bira svoju komponentu)", "ui.selection": "Odabir", "ui.selection_modifiers": "Modifikatori odabira", "ui.selection_shift_add_only": "Shift+klik dodaje bez uklanjanja", @@ -4561,6 +4924,11 @@ "ui.sketch_intersect_selection_filter": "Filtar odabira", "ui.sketch_modifiers": "Modifikatori skice", "ui.sketch_project_geometry": "Geometrija", + "ui.sketch_report.all_constrained": "Sve je potpuno ograničeno.", + "ui.sketch_report.dof": "Stupnjevi slobode", + "ui.sketch_report.loose": "Nije potpuno ograničeno", + "ui.sketch_report.status": "Status", + "ui.sketch_report.title": "Izvješće o skici", "ui.sketch_scale": "Skaliranje skice", "ui.sketches": "Skice", "ui.slice": "Presijeci", @@ -4670,7 +5038,14 @@ "ui.viewport_status_key": "Prikaži legendu za {name}", "ui.viewport_status_minimum_radius": "Minimalni polumjer", "ui.viewport_status_pairs": "Parovi: {count}", + "ui.viewport_status_section_hatch": "{setting} · šrafura", + "ui.viewport_status_section_plane": "Trenutna ravnina", + "ui.viewport_status_section_tilted": "nagnuto · {offset}", "ui.viewport_status_stripes": "Pruge: {count}", + "ui.viewport_status_summary_clear": "Isključi sve", + "ui.viewport_status_summary_heading": "U ovom prikazu", + "ui.viewport_status_summary_label": "{name} i još stavki: {count}", + "ui.viewport_status_summary_more": "+{count}", "ui.viewport_theme_follow": "Prati temu", "ui.visual_shaded_hidden_edges": "Osjenčano sa skrivenim bridovima", "ui.visual_shaded_with_edges": "Osjenčano s bridovima", diff --git a/assets/locales/hu.json b/assets/locales/hu.json index c31fd3f48c..8ca09b7674 100644 --- a/assets/locales/hu.json +++ b/assets/locales/hu.json @@ -60,6 +60,7 @@ "BLEND SURFACE": "ÁTMENETI FELÜLET", "BOUNDARY SURFACE": "HATÁRFELÜLET", "BROWSER": "BÖNGÉSZŐ", + "Back to the original order": "Vissza az eredeti sorrendhez", "Ball": "Gömbcsukló", "Bar": "Rúd", "Base Center": "Alap középpontja", @@ -595,6 +596,8 @@ "Snap to face and edge centres": "Illesztés a lapok és élek középpontjához", "SolidCraft cannot submit reports directly. Submit on GitHub; no token is needed in the app.": "A SolidCraft nem tud közvetlenül jelentést beküldeni. Küldje be a GitHubon; az alkalmazásban nincs szükség tokenre.", "SolidCraft closed before these designs were saved. Their last autosave can be opened.": "A SolidCraft bezárult, mielőtt ezek a tervek mentve lettek volna. Az utolsó automatikus mentésük megnyitható.", + "Sort A to Z": "Rendezés A-tól Z-ig", + "Sort Z to A": "Rendezés Z-től A-ig", "Spacing 2 must be a non-zero number": "A 2. térköznek nullától eltérő számnak kell lennie", "Spacing must be a non-zero number": "A térköznek nullától eltérő számnak kell lennie", "Specialise": "Specializálás", @@ -819,6 +822,7 @@ "cmd.appearance_pick_up": "Megjelenés felvétele", "cmd.appearance_replace": "Megjelenés cseréje", "cmd.assembly_bom": "Anyagjegyzék", + "cmd.assembly_mass_properties": "Tömegjellemzők", "cmd.body.print_face": "Nyomtatási lap", "cmd.body_rename": "Test átnevezése", "cmd.browser_collapse_all": "Összes összecsukása", @@ -1244,6 +1248,7 @@ "cmd.view.render_browser": "Renderböngésző", "cmd.view.render_dock": "Renderpanel áthelyezése", "cmd.view.render_focus": "Renderfókusz nézet", + "cmd.view.status_details": "Nézetablak állapotának részletei", "cmd.view.status_items": "Nézetablak állapotelemei", "cmd.view_ambient_occlusion": "Környezeti takarás", "cmd.view_angle_readout": "Nézetszögek", @@ -1387,21 +1392,168 @@ "from the rule": "a szabályból", "help.apache": "Apache 2.0 licenc", "help.appearance.assign": "A kijelölt testeknek, lapoknak vagy komponenseknek színt vagy anyagjellegű megjelenést ad, a tömegük megváltoztatása nélkül. Használja az alkatrészek megkülönböztetésére vagy egy felület előnézetéhez.", + "help.assembly.bom": "Felsorolja a terv vagy egy komponens alkatrészeit darabszámukkal, cikkszám szerint, és a listát CSV- vagy JSON-fájlba mentheti. Használja rendelési és összeszerelési alkatrészlistaként.", "help.attributions": "Forrásmegjelölések (ATTRIBUTION.md)", "help.build": "{version} verzió · commit: {commit} · készült: {date}", + "help.canvas.decal": "Képet helyez a modell egy sík lapjára, mint egy matricát. Használja címke, logó vagy nyomtatott minta megjelenítésére egy alkatrészen.", + "help.canvas.edit": "Módosít egy vásznat: a helyzetét, méretét, szögét, átlátszatlanságát, tükrözését, láthatóságát vagy nevét. A referenciakép és a fölé rajzolt vázlat egymáshoz igazítására használja.", + "help.canvas.insert": "PNG- vagy JPEG-képet helyez egy síkra vagy lapra átlátszó referenciaként. Használja fénykép vagy rajz átrajzolására egy vázlatban, a képet ismert méretre skálázva.", + "help.component.break_link": "Leválaszt egy kapcsolt komponenst a tervfájljáról, a testeit változatlanul meghagyva. Akkor használja, ha az alkatrésznek már nem kell követnie az eredeti fájl módosításait.", + "help.component.capture_position": "Megtartja a kézzel mozgatott alkatrészek helyzetét, így az lesz az alkatrészek új helye. Akkor használja, ha olyan alkatrészeket húzott a helyükre, amelyeket egyetlen csukló sem tart.", + "help.component.create": "Új, üres komponenst ad a tervhez az aktív komponensen belül, hacsak nem választ másik szülőt. Használja, hogy az összeállítás új alkatrésze saját helyet kapjon a testei és vázlatai számára.", "help.component.finish_edit": "Elmenti a helyben szerkesztett alkatrészt, visszatér az összeállításhoz, és frissíti azt az alkatrész alapján. Akkor használja, ha végzett a kapcsolt alkatrész módosításaival.", + "help.component.from_bodies": "Minden kijelölt testből külön komponenst készít, a test nevén. Használja, ha egy különálló testekből épített tervből olyan összeállítást szeretne, amelynek alkatrészei összekapcsolhatók és mozgathatók.", + "help.component.insert_design": "Beszúr egy másik mentett tervet a fájljához kapcsolt komponensként, a szilárd testeivel úgy, ahogy ott el vannak helyezve. Használja egy alkatrész újrafelhasználására egy összeállításban; a későbbi módosításokat a Kapcsolatok frissítése veszi át.", + "help.component.properties": "Megmutatja egy komponens térfogatát, tömegét, tömegközéppontját és tehetetlenségi nyomatékait a testei anyagai alapján. Használja egy összeállítás súlyának és egyensúlyának ellenőrzésére.", + "help.component.revert_position": "Visszateszi a kézzel mozgatott alkatrészeket oda, ahol voltak, elvetve a nem rögzített mozgatásokat. Használja egy összeállításban végzett próbamozgatások visszavonására.", + "help.component.update_links": "Újra beolvassa a kapcsolt tervfájlokat, és lecseréli a testeket, ahol megváltoztak. Használja, miután szerkesztett egy olyan alkatrészt, amelyet ez az összeállítás használ.", + "help.config.configure": "Paramétereket, elemeket, anyagokat vagy megjelenéseket ad a konfigurációs táblázathoz oszlopként. Használja egy terv változatainak elkészítésére, például egy alkatrész méretváltozataihoz.", + "help.config.preview": "Ellenőrzi, hogy a konfigurációs táblázat egy sora újraépíthető-e, a terv és az előzmények módosítása nélkül. Használja, mielőtt olyan változatra vált, amelyben nem biztos.", + "help.config.rules": "Megmutatja a konfigurációs táblázat szabályait, sorait és azt, hogy melyik sor az aktív. Használja annak megtekintésére, hogyan vannak beállítva egy terv változatai.", + "help.config.table": "Megjeleníti a konfigurációs táblázatot: változatonként egy sort, paraméterenként vagy elemenként egy oszlopot, és azt, hogy a terv még egyezik-e az aktív sorral. Használja egy terv változatainak áttekintésére.", "help.connect_note": "Indítsa újra az egyes asszisztenseket a SolidCraft szerver betöltéséhez. Minden fájlról .bak biztonsági másolat készült a módosítás előtt.", "help.connect_title": "AI-asszisztensek csatlakoztatása", + "help.construct.axis.edge": "Szerkesztőtengelyt hoz létre egy egyenes él vagy vázlatvonal mentén. Használja forgatás, körminta vagy csukló tengelyeként.", + "help.construct.axis.normal_to_face": "Szerkesztőtengelyt hoz létre egy ponton át, a laphoz merőlegesen. Használja olyan tengelyként, amely egyenesen kiáll egy felületből.", + "help.construct.axis.through_cylinder": "Szerkesztőtengelyt hoz létre egy henger-, kúp- vagy tóruszlap, illetve egy kör alakú él középpontján át. Használja elemek forgatására vagy mintázására egy kerek alkatrész körül.", + "help.construct.axis.two_planes": "Szerkesztőtengelyt hoz létre ott, ahol két sík vagy sík lap találkozik. Használja, ha a szükséges tengely a két lap közötti vonal.", + "help.construct.axis.two_points": "Szerkesztőtengelyt hoz létre két ponton, csúcson vagy vázlatponton át. Használja két ismert helyzet között húzódó tengelyhez.", + "help.construct.plane.along_path": "Szerkesztősíkot hoz létre egy élre vagy görbére merőlegesen, annak egy adott helyén. Használja söpréshez való profil vagy egy útvonal keresztmetszetének megrajzolására.", + "help.construct.plane.at_angle": "Szerkesztősíkot hoz létre, amely egy síktól vagy laptól egy tengely vagy él körül adott szöggel van elforgatva. Használja ferde vázlatokhoz, például szögben álló konzolhoz.", + "help.construct.plane.midplane": "Szerkesztősíkot hoz létre két sík vagy sík lap között félúton, vagy, ha szögben találkoznak, felezve őket. Használja tükrözés szimmetriasíkjaként.", + "help.construct.plane.offset": "Szerkesztősíkot hoz létre egy síkkal vagy sík lappal párhuzamosan, adott távolságra vagy egy ponton át. Használja, hogy egy lap fölött meghatározott magasságban rajzoljon vázlatot.", + "help.construct.plane.perpendicular": "Szerkesztősíkot hoz létre, amely tartalmaz egy egyenest vagy élt, és merőleges egy síkra vagy lapra. Használja, hogy egy modellen egy él mentén függőlegesen álló vázlatot rajzoljon.", + "help.construct.plane.tangent": "Szerkesztősíkot hoz létre, amely egy pontban érint egy hengert, kúpot, gömböt vagy tórust. Használja egy kerek alkatrész oldalán való vázlatrajzoláshoz.", + "help.construct.plane.three_points": "Szerkesztősíkot hoz létre három ponton át. Használja olyan síkhoz, amelyet a modell pontjai határoznak meg, például három sarok.", + "help.construct.plane.two_edges": "Szerkesztősíkot hoz létre két olyan egyenes élen vagy vonalon át, amelyek egy síkban fekszenek. Használja egy alkatrészen átmenő síkhoz, például két szemközti élen át.", + "help.construct.point.along_path": "Szerkesztőpontot hoz létre egy él vagy görbe egy adott helyén. Használja referenciaként egy útvonal valamely pontján.", + "help.construct.point.center": "Szerkesztőpontot hoz létre egy kör alakú él, vázlatkör vagy -ív, gömb vagy tórusz középpontjában. Használja referenciaként furatokhoz, tengelyekhez és csuklókhoz.", + "help.construct.point.edge_plane": "Szerkesztőpontot hoz létre ott, ahol egy él vagy vonal áthalad egy síkon vagy sík lapon. Használja annak megjelölésére, hogy egy útvonal hol metsz egy síkot.", + "help.construct.point.three_planes": "Szerkesztőpontot hoz létre ott, ahol három sík vagy sík lap találkozik. Használja sarokreferenciaként akkor is, ha a modellnek ott nincs csúcsa.", + "help.construct.point.two_edges": "Szerkesztőpontot hoz létre ott, ahol két él vagy vonal találkozik, illetve ahol a legközelebb kerülnek egymáshoz. Használja referenciaként egy virtuális sarokban.", + "help.construct.point.vertex": "Szerkesztőpontot hoz létre egy csúcson vagy vázlatponton. Használja olyan referenciaként, amely megmarad akkor is, ha a körülötte lévő vázlat vagy test el van rejtve.", + "help.contact.check": "Felsorolja az érintkezési készletekben azokat az alkatrészpárokat, amelyek a jelenlegi helyzetükben áthatják egymást. Használja az ütközések megkeresésére, mielőtt csuklókat hajtana meg vagy mozgásvizsgálatot futtatna.", + "help.contact.create": "Két vagy több alkatrészt érintkezési készletbe csoportosít; a testeik nem hatolhatnak egymásba, miközben a csuklók mozognak. Használja olyan alkatrészekhez, amelyeknek érintkezéskor meg kell állniuk, például egy kar és az ütközője.", + "help.contact.disable_all": "Kikapcsolja az érintkezést, így a meghajtott csuklók és a mozgásvizsgálatok már nem állnak meg ott, ahol az alkatrészek érintkeznek. Használja az alkatrészek újbóli szabad mozgatásához, vagy egy nagy összeállítás gyors válaszkészségének megtartásához.", + "help.contact.edit": "Módosít egy érintkezési készletet: a benne lévő alkatrészeket, a nevét, vagy azt, hogy el van-e nyomva; a készletet törölni is tudja. Használja annak beállítására, mely alkatrészek ne hatolhassanak egymásba.", + "help.contact.enable_all": "Az összeállítás minden alkatrészét egyetlen érintkezési készletbe teszi, így a csuklók mozgása közben két alkatrész sem hatolhat egymásba. Használja egy egész mechanizmus gyors ellenőrzésére.", + "help.contact.enable_sets": "Bekapcsolja az érintkezést az Ön által megadott érintkezési készletekre, így a meghajtott csuklók és a mozgásvizsgálatok megállnak ott, ahol az alkatrészeik egymásba hatolnának. Használja a készletek visszakapcsolására az érintkezés kikapcsolása után.", + "help.contact.list": "Felsorolja a terv érintkezési készleteit, a bennük lévő alkatrészeket és azt, hogy be van-e kapcsolva az érintkezés. Használja annak ellenőrzésére, hogyan van beállítva az érintkezés, mielőtt az összeállítást meghajtja.", + "help.document.inspect": "Áttekintést ad a tervről: a komponenseiről, testeiről, vázlatairól, elemeiről és paramétereiről. Használja, hogy eligazodjon egy olyan tervben, amelyet nem Ön épített.", + "help.document.units": "Megváltoztatja azt a mértékegységet, amelyben a terv a hosszakat mutatja, például mm vagy hüvelyk. A geometria mérete nem változik; csak a megjelenítés és a mértékegység nélküli számok követik.", + "help.edit.checkpoint": "Megjegyzi a tervet, az aktív vázlatot és a visszavonási előzményeket a jelenlegi állapotukban. Használja, mielőtt valami kockázatosat próbál ki, amelyből később vissza szeretne térni.", + "help.edit.restore_checkpoint": "Visszatér egy ellenőrzőponthoz, a visszavonási előzményekkel együtt. Használja az ellenőrzőpont óta végzett összes művelet egyetlen lépésben való elvetésére.", + "help.explode.create": "Robbantott nézetet hoz létre, amely az alkatrészeket eltolja az összeállítás középpontjától, vagy az Ön által megadott mozgatásokkal. Használja annak bemutatására, hogyan áll össze egy összeállítás, útmutatókhoz vagy prezentációkhoz.", "help.face.delete": "Eltávolítja a kijelölt lapokat, és hagyja, hogy a szomszédos lapok bezárják a rést, vagy felületként nyitva hagyja a rést. Használja egy útban lévő lekerekített él, furat vagy dudor eltávolítására.", + "help.file.insert_mesh": "3MF-, STL-, OBJ-, PLY- vagy glTF-fájlt ad a tervhez hálótestekként egy új komponensben. Használja egy letöltött vagy szkennelt modell referenciaként a terv mellé helyezésére.", + "help.file.insert_step": "STEP-, IGES-, FreeCAD- vagy Rhino-fájlt ad a tervhez szilárd testekként egy új komponensben; egy összeállítás alkatrészei beágyazott komponensek lesznek. Használja beszállítók vagy más CAD-programok modelljeinek behozatalára.", + "help.file.save_mesh": "A testeket STL-, 3MF- vagy OBJ-hálóként menti, a görbült lapok Ön által választott simaságával. Használja alkatrészek 3D nyomtatásra vagy hálót olvasó programokhoz való előkészítésére.", + "help.file.send_to_slicer": "A kijelölt testeket 3MF-fájlként menti, és megnyitja a kiválasztott szeletelőben. Használja, hogy egyetlen lépésben jusson el a modelltől a nyomtatásig.", "help.font_details": "A felület az egui beépített betűtípusait használja (Ubuntu Light és Hack, az Ubuntu Font Licence és az MIT/Bitstream Vera licenc alatt; Noto Emoji és az emoji ikonbetűtípus, OFL és MIT alatt). A vázlatszöveg a Liberation Sans betűtípust használja (SIL OFL 1.1). A Crafting Apps opcionális betűtípuscsomagja, a craft-fonts (BIZ UDPGothic, Shippori Mincho, BIZ UDMincho, Noto Sans CJK SC, Noto Sans Arabic; SIL OFL 1.1) nincs a SolidCraft-buildekbe csomagolva.", "help.fonts": "Betűtípusok", + "help.frame.member": "Profilt, például csövet vagy alumínium extrudált profilt helyez egy keretvázlat vonalai mentén, vonalanként egy szerkezeti elemet. Használja keretek, állványok és hegesztett szerkezetek építésére.", + "help.inspect.accessibility": "Megszínezi azokat a lapokat, amelyeket egy szerszám egy irányból elér, és azokat, amelyeket nem. Használja annak ellenőrzésére, hogy az alkatrész megmunkálható vagy önthető-e arról az oldalról.", + "help.inspect.center_of_mass": "Megmutatja a testek tömegközéppontját külön-külön és együtt, egyenletes sűrűség mellett. Használja az egyensúly ellenőrzésére és annak megállapítására, hol borul fel egy alkatrész.", + "help.inspect.colored_components": "Minden komponensnek külön ideiglenes színt ad, a megjelenések módosítása nélkül. Használja egy összeállítás alkatrészeinek első pillantásra való megkülönböztetésére.", + "help.inspect.compare": "Összehasonlít két testet, és megmutatja, hol van az egyiken olyan anyag, amely a másikon nincs, a térfogattal és az eltéréssel együtt. Használja egy módosított vagy importált alkatrész eredetivel való összevetésére.", + "help.inspect.continuity": "Ellenőrzi, milyen simán találkoznak a lapok az éleik mentén: réseket, töréseket vagy görbületugrásokat keres, és a varratokat beszínezi. Használja annak megkeresésére, hol nem csatlakoznak tisztán a felületek.", + "help.inspect.curvature_comb": "Fésűket rajzol görbék, élek vagy lapok mentén; a fogaik azt mutatják, mennyire hajlanak. Használja a lapos részek, törések és az egyenetlen görbület felismerésére.", + "help.inspect.curvature_map": "A lapokat aszerint színezi, mennyire görbülnek, pirossal ott, ahol kicsi a sugár. Használja a szűk helyek megkeresésére és egy felület simaságának megítélésére.", + "help.inspect.draft": "A lapokat a húzási irányhoz mért dőlésszögük szerint színezi, és megjelöli azokat, amelyeknek túl kicsi. Használja egy alkatrész ellenőrzésére öntés vagy fröccsöntés előtt.", + "help.inspect.environment_map": "Tükröző, tükörszerű felülettel jeleníti meg a modellt. Használja a felületek lefutásának megítélésére abból, ahogyan a tükröződések végigfutnak rajtuk.", + "help.inspect.interference": "Megkeresi az átfedő testeket, és minden átfedést a térfogatával együtt megmutat. Használja egy összeállítás ütköző alkatrészeinek ellenőrzésére.", + "help.inspect.isocurve": "Megrajzolja a lapok izoparametrikus görbéit, vagyis azt a rácsot, amelyre a felület épül. Használja annak megtekintésére, hogyan fut egy felület, és hol sűrűsödik össze.", + "help.inspect.measure": "Hosszakat, távolságokat, szögeket, területeket és sugarakat mér a kiválasztott elemek között, vagy testek térfogatát és tömegét. Használja méretek és hézagok ellenőrzésére.", + "help.inspect.minimum_radius": "Megkeresi a görbék, élek vagy lapok legkisebb görbületi sugarát, és azt, hogy hol van. Használja annak ellenőrzésére, hogy egy szerszám vagy fúvóka követni tudja-e az alakot.", + "help.inspect.section": "Egy sík mentén felvágja a nézetet, és sraffozza a vágott lapokat, a modell módosítása nélkül. Használja alkatrészek és összeállítások belsejének megtekintésére.", + "help.inspect.zebra": "Fekete-fehér csíkokat tükröz a modell lapjain. Használja a simaság megítélésére: az élnél megszakadó vagy megtörő csíkok rossz átmenetet jeleznek.", + "help.joint.as_built": "Összekapcsol két alkatrészt ott, ahol már vannak, egyiket sem mozgatva, és beállítja, hogyan mozoghatnak még. Használja olyan alkatrészekhez, amelyeket már a végleges helyükre tett.", + "help.joint.cam": "Egy forgó bütyköt kapcsol a követőhöz, így a követő elcsúszik vagy kileng, ahogy a bütyök körvonala elhalad alatta. Használja bütykökhöz, excenterekhez és szelephajtásokhoz.", + "help.joint.create": "Összekapcsol két alkatrészt a kiválasztott csuklóeredeteknél, és a másodikat a helyére mozgatja; a csukló típusa határozza meg, hogyan mozoghatnak még. Használja olyan összeállítás építésére, amelynek alkatrészei úgy mozognak, mint a valódi mechanizmus.", + "help.joint.drive": "Beállítja egy csukló szögét vagy távolságát, és vele együtt mozgatja az alkatrészeket a korlátain belül; a kapcsolt csuklók és a zárt hurkok követik. Használja annak ellenőrzésére, hogy a mechanizmus a tervezett módon mozog-e.", + "help.joint.gear": "Két forgó csuklót kapcsol egy áttétellel, így az egyik meghajtása elforgatja a másikat. Használja homlokfogaskerekekhez és belső fogazású kerekekhez; az áttétel az osztókör sugarából vagy a fogszámból adódik.", + "help.joint.limits": "Beállítja, meddig foroghat vagy csúszhat egy csukló, minimum- és maximumértékkel, vagy törli ezeket a korlátokat. Használja, hogy egy zsanér, csúszka vagy kar a valódi mozgástartományán belül maradjon.", + "help.joint.motion_link": "Két csuklót kapcsol össze egy aránnyal és egy eltolással, így az egyik meghajtása mozgatja a másikat. Használja olyan állandó összefüggéshez a csuklók között, amelyet a Fogaskerék, a Fogasléc és fogaskerék vagy a Csavar nem fed le.", + "help.joint.origin": "Elnevezett csuklóeredetet helyez egy alkatrészre: egy lap vagy kerek vég középpontjában, egy él közepén, egy sarokban vagy egy pontban. Használja annak előkészítésére, hol kapcsolódjanak a csuklók; a lapon kijelölt eredet követi a lapot, ha az alkatrész mérete változik.", + "help.joint.rack_pinion": "Egy forgó fogaskereket kapcsol egy csúszó fogaslécre, így a fogasléc annyit mozdul, amekkora osztókör-hosszon a fogaskerék legördül. Használja fogaslécmeghajtásokhoz, kormányzáshoz és lineáris szánokhoz.", + "help.joint.reach": "Egy alkatrész egy pontját a cél felé mozgatja a láncán lévő csuklók módosításával, a korlátaikon belül. Használja egy mechanizmus beállítására aszerint, hová kell érnie a végének, például egy robotkar hegyének.", + "help.joint.rigid_group": "A kijelölt alkatrészeket úgy, ahogy vannak, összezárja, így egyként mozognak. Használja olyan alkatrészekhez, amelyek összetartoznak, és nincs köztük mozgás.", + "help.joint.screw": "Összekapcsolja a forgást és a csúszást egy menetemelkedéssel, így minden fordulat ennyivel halad előre. Használja vezetőorsókhoz, csavarokhoz és menetes orsókhoz.", + "help.library.apply": "Anyagot vagy megjelenést alkalmaz egy helyi könyvtárfájlból testekre, lapokra vagy komponensekre. Használja egy másik tervből elmentett anyagok újrafelhasználására.", + "help.library.export": "Elmenti a terv anyagait és megjelenéseit egy könyvtárfájlba. Használja a megosztásukra vagy más tervekben való újrafelhasználásukra.", + "help.library.inspect": "Ellenőriz egy könyvtárfájlt, és felsorolja az anyagait és megjelenéseit, a terv módosítása nélkül. Használja annak megtekintésére, mit tartalmaz egy könyvtár, mielőtt alkalmazza.", "help.licences": "Licencek", "help.material.assign": "Megadja, miből készültek a kijelölt testek, például acélból, alumíniumból vagy ABS-ből. Használja, hogy a tömeg és a súly megegyezzen a valódi alkatrészével.", + "help.material.properties.validate": "Ellenőrzi egy anyagadatlap számainak következetességét és a hiányzó értékeket. Használja, mielőtt az adatokra támaszkodik a tömeg vagy a merevség számításánál.", + "help.mesh.convert": "Egy hálótestet, például egy STL-t szerkeszthető szilárd testként épít újra, sík és hengeres lapokkal. Használja importált gépalkatrészek módosítására úgy, mintha modellezett testek lennének.", "help.mit": "MIT licenc", + "help.motion.study": "Csuklóértékeket állít be egy idővonal lépésein, és animációként lejátssza őket. Használja egy mechanizmus mozgásának végignézésére egy sorozaton át, és közben a hézagok ellenőrzésére.", + "help.motion.trace": "Követi azt az utat, amelyet egy alkatrész egy pontja bejár, miközben a mozgásvizsgálat lejátszódik vagy egy csuklót meghajtanak, és a megerősítéskor 3D vázlatgörbeként megtartja. Használja annak megtekintésére, hová ér el egy mechanizmus, vagy az útvonalára építeni.", "help.notice": "NOTICE", + "help.occurrence.ground": "Egy alkatrészt a helyén rögzít, vagy újra felold, így a csuklók a többi alkatrészt mozgatják körülötte. Használja a vázhoz vagy alaphoz, amelyre az összeállítás többi része épül.", "help.occurrence.move": "Az összeállítás egy alkatrészét csak a csuklói által megengedett szabadságfokok mentén mozgatja. Jelölje ki az alkatrész egy lapját, majd húzza az egyik elérhető nyilat vagy ívet.", "help.parameters.change": "Megnyitja a terv méreteit vezérlő elnevezett értékek és kifejezések táblázatát. Használja egy méret egy helyen való módosítására, hogy az azt használó összes elem kövesse.", + "help.parameters.export": "A terv paramétereit CSV- vagy JSON-fájlba menti. Használja táblázatkezelőben való szerkesztésükre, vagy másik tervbe való átvitelükre.", + "help.parameters.graph": "Megmutatja, mely paraméterek és elemek mely másokat használják, valamint a köztük lévő hurkokat vagy hibákat. Használja annak megtekintésére, mit érint egy paraméter módosítása.", + "help.parameters.import": "CSV- vagy JSON-fájlból olvas be paramétereket a tervbe, vagy mindet, vagy egyet sem. Használja értékek táblázatkezelőből vagy másik tervből való behozatalára.", + "help.parameters.move": "Egy felhasználói paramétert a listában egy másik elé vagy mögé helyez. Használja az összetartozó paraméterek együtt tartására.", + "help.parts.fastener": "Szabványos csavart, anyát, alátétet, csapot vagy csapágyat ad hozzá a kiválasztott méretben. Furatra helyezve csuklóval illeszkedik a furatba, így nem kell magának modelleznie a kötőelemeket.", + "help.parts.insert": "Szabványos alkatrészt szúr be a kiválasztott családban, méretben és hosszban. Egy furat peremére vagy falára helyezve merev csuklóval illeszkedik a furathoz.", + "help.parts.library": "Megnyitja a szabványos alkatrészeket: csavarokat, anyákat, alátéteket, szárnélküli csavarokat, illesztőcsapokat és csapágyakat szabványos méretekben. Használja szabványos alkatrész hozzáadására ahelyett, hogy maga modellezné.", + "help.parts.resize": "Megváltoztatja egy beszúrt szabványos alkatrész méretét vagy hosszát, és újraépíti; a csuklói a helyén tartják. Használja, ha egy csavar túl hosszúnak bizonyul, vagy egy furathoz másik méret kell.", + "help.plastic.assign_rule": "Műanyag szabályt rendel testekhez, így a műanyag elemeik ennek dőlését és hézagát veszik alapértelmezettnek. Használja egy öntött alkatrész egyszeri beállítására az összes elemére.", + "help.plastic.boss": "Csavardudort ad egy lapra: oszlopot opcionális furattal, dőléssel, tövi lekerekítéssel és támasztó bordákkal. Használja csavarpontokhoz öntött vagy nyomtatott házakban.", + "help.plastic.lip": "Peremet vagy hozzá illő hornyot ad egy héjazott test pereme mentén. Használja, hogy két házfél egymáson pozicionálódjon, és szorosan záródjon.", + "help.plastic.manage_rules": "Műanyag szabályokat hoz létre és szerkeszt: anyag, falvastagság, sugarak, hézag és dőlés. Használja őket egy öntött alkatrész elemeinek egységesen tartására.", + "help.plastic.rest": "Támaszt ad egy lapra: kerek vagy téglalap alakú, tömör vagy üreges párnát dőléssel. Használja alátámasztásként vagy távtartóként egy öntött házban.", + "help.plastic.snap_fit": "Pattintós illesztésű kampót ad egy lapra, megadott hosszal, vastagsággal és reteszeléssel. Használja alkatrészek csavar nélküli összepattintására.", "help.pointcloud.insert": "PLY, XYZ vagy PTS szkennelést szúr be pontfelhőként a modell mellé. Használja referenciaként, amikor mért adatokból épít modellt.", + "help.sheet.convert": "Egyenletes vastagságú lemezt vagy héjazott testet lemeztestté alakít, a kiválasztott éleken hajlításokkal. Használja egy meglévő alkatrész kiteríthetővé tételére.", + "help.sheet.export_bend_table": "Egy lemeztest hajlítási táblázatát CSV- vagy JSON-fájlba menti: szögeket, sugarakat és a hajlítások helyzetét a kiterített állapotban. Használja a lemezhajlító géphez vagy a műhelynek.", + "help.sheet.export_dxf": "A lapmintát DXF-fájlba menti, körvonalakkal, hajlítási vonalakkal és opcionális hajlítási megjegyzésekkel. Használja lézeres, plazma- vagy vízsugaras vágáshoz.", + "help.sheet.flange": "Peremet ad hozzá: alaplemezt egy vázlatból, falat egy nyitott vázlatkontúr mentén, vagy hajlított falat a lemez élein. Használja lemezalkatrészek felépítésére.", + "help.sheet.flat_pattern": "Létrehozza egy lemeztest lapmintáját a méretével, kivágásaival és hajlítási vonalaival. Használja a kiterített darab ellenőrzésére a kivágás előtt.", + "help.sheet.fold": "Meghajlít egy lemezt egy vázlatvonal mentén adott szöggel és sugárral. Használja egy sík lemez behajtására, miután kivágásokat készített rajta.", + "help.sheet.hem": "Egy lemezélt visszahajt önmagára. Használja biztonságos, merev élekhez, éles sorják nélkül.", + "help.sheet.manage_rules": "Lemezszabályokat hoz létre és szerkeszt: vastagság, hajlítási sugár, K-tényező, kiengedések és rések. Használja őket a műhelye anyagaihoz és szerszámaihoz igazítására.", + "help.sheet.path_flange": "Peremek láncát adja egy lemezél mentén, egy nyitott vázlatvonal-lánc nyomán. Használja U vagy Z alakú profilokhoz egyetlen lépésben.", + "help.sheet.perforate": "Furatmezőt ad a teljes sík lemezre, eltolt vagy négyzetes mintában. Használja szellőzőnyílásokhoz, rácsokhoz és könnyebb panelekhez.", + "help.sheet.refold": "Egy kiterített lemeztestet visszahajt. Használja, miután elemeket adott a sík lemezhez.", + "help.sheet.unfold": "Kiterít egy lemeztest összes hajlítását. Használja olyan furatok vagy vágások hozzáadásához, amelyeknek át kell menniük egy hajlításon, majd az Újrahajlítással hajtsa vissza.", + "help.sketch.arc.center_point": "Körívet rajzol egy középpont körül, egy kezdőponttól egy végpontig. Használja, ha az ív középpontja ismert, például egy furat vagy egy forgáspont körüli sugárhoz.", + "help.sketch.arc.tangent": "Olyan ívet rajzol, amely egy vonal vagy ív végén indul, és érintőlegesen folytatódik. Használja a lánc folytatására sima hajlattal sarok helyett.", + "help.sketch.arc.three_point": "Ívet rajzol egy kezdőponttól egy végpontig egy harmadik ponton át. Használja, ha az ívnek adott pontokon kell áthaladnia, nem pedig egy ismert középpont körül futnia.", + "help.sketch.arc3d": "Pontos körívet rajzol a térben három ponton át, bekapcsolt 3D vázlattal. Használja útvonalak és vezérgörbék hajlataihoz, amelyek elhagyják a vázlatsíkot.", + "help.sketch.auto_constrain": "Hozzáadja azokat a kényszereket, amelyek a vázlatban már majdnem megvannak, például vízszintest, párhuzamost vagy egybeesőt, majd addig méretezi, amíg teljesen kényszerezett nem lesz. Használja egy hozzávetőlegesen megrajzolt vázlat egy lépésben való rögzítésére.", + "help.sketch.auto_constrain_from_datum": "Úgy működik, mint az Automatikus kényszerezés, de a helyzeteket az origó helyett egy Ön által kiválasztott viszonyítási pontból méri. Használja, ha az alkatrész egyetlen referenciapontból van kiosztva, például egy sarokból vagy egy furatból.", + "help.sketch.blend_curve": "Két görbe végét egy sima spline-nal köti össze, amely mindkettőt érinti, kívánság szerint görbületfolytonosan is. Használja görbék közötti rés áthidalására törés nélkül.", + "help.sketch.break": "Darabokra vágja a görbét ott, ahol más görbék keresztezik, vagy a rajta elhelyezett pontoknál. Használja, ha a görbe egy részének saját kényszerre van szüksége, vagy külön kell törölni.", + "help.sketch.cancel": "Elhagyja a vázlatot, és elvet mindent, ami a létrehozása vagy szerkesztésre való megnyitása óta megváltozott. A Visszavonás visszahozza a módosításokat.", + "help.sketch.centerline": "Vonalakat vált normál vonal és középvonal között; a középvonalak olyan tengelyek, amelyeket a profilok kihagynak. Használja a középvonalat forgatás, tükrözés vagy szimmetriakényszer tengelyeként.", + "help.sketch.chamfer.distance_angle": "Letöri a két vonal közötti sarkot, az első vonal menti távolsággal és egy szöggel megadva. Használja, ha a rajz a letörést hosszal és szöggel adja meg, például 2 mm × 30°.", + "help.sketch.chamfer.equal_distance": "Letöri a két vonal közötti sarkot úgy, hogy mindkét vonal mentén ugyanannyit vesz vissza. Használja a körvonalak szokásos 45°-os letöréséhez.", + "help.sketch.chamfer.two_distance": "Letöri a két vonal közötti sarkot, mindkét vonal mentén külön távolsággal megadva. Használja a nem 45°-os, két hosszal megadott letörésekhez.", + "help.sketch.circle.center": "Kört rajzol a középpontjából és az átmérőjéből. Használja furatokhoz, dudorokhoz és más kerek elemekhez, amelyeknek ismert a középpontja.", + "help.sketch.circle.three_point": "Kört rajzol három ponton át. Használja, ha a körnek adott pontokon kell áthaladnia, például meglévő geometria pontjain.", + "help.sketch.circle.three_tangent": "Olyan kört rajzol, amely három vonalat, kört vagy ívet érint. Használja, hogy egy kört három görbe közötti térbe illesszen, például egy háromszög beírt körét.", + "help.sketch.circle.two_point": "Kört rajzol az átmérője két végpontjából. Használja, ha a kör szélének pontjai ismertek, nem a középpontja.", + "help.sketch.circle.two_tangent": "Adott méretű kört rajzol, amely két vonalat, kört vagy ívet érint. Használja két görbe közötti állandó sugarú lekerekítéshez.", + "help.sketch.conic": "Kúpgörbét rajzol két végpont között, amelyet egy csúcspont és egy ró (ρ) érték alakít. Használja olyan sima átmenetekhez, amelyek teltségét hangolni szeretné, a lapostól a csaknem éles sarokig.", + "help.sketch.constrainer": "Alkalmazza azt a kényszert, amely egy vagy két kijelölt görbénél már majdnem fennáll, például vízszintes, párhuzamos, érintő vagy egyenlő. Használja egy kézzel rajzolt vázlat rendbetételére, egyszerre egy összefüggéssel.", + "help.sketch.constraint.coincident": "Egy pontot egy másik pontra, görbére vagy vázlattengelyre illeszt. Használja egy profil réseinek lezárására, vagy egy pont vonalra helyezésére.", + "help.sketch.constraint.collinear": "Két vonalat egy egyenesre illeszt. Használja megszakított élek egy vonalba hozására, például egy bevágás két oldalán.", + "help.sketch.constraint.concentric": "Két körnek vagy ívnek ugyanazt a középpontot adja. Használja gyűrűkhöz, süllyesztett furatokhoz és vállas tengelyekhez.", + "help.sketch.constraint.curvature": "Azt éri el, hogy a végpontban találkozó két görbe ugyanazzal a görbülettel folytatódjon (G2). Használja ott, ahol az érintős csatlakozás nem elég sima, például látható felületeken.", + "help.sketch.constraint.equal": "A vonalakat azonos hosszúságúvá, a köröket és íveket azonos sugarúvá teszi. Használja, hogy egy méret több egyező elemet vezéreljen.", + "help.sketch.constraint.fix": "Pontokat vagy görbéket rögzít a helyükön, vagy újra felold. Használja olyan geometriához, amelynek nem szabad elmozdulnia a vázlat húzásakor vagy megoldásakor.", + "help.sketch.constraint.horizontal_vertical": "Egy vonalat, vagy két pont közötti vonalat vízszintessé vagy függőlegessé tesz, amelyikhez közelebb áll. Használja egy körvonal vázlattengelyekhez igazítására.", + "help.sketch.constraint.midpoint": "Egy pontot egy vonal közepére helyez, vagy két vonal felezőpontját egybeejti. Használja geometria egy élre való középre igazítására.", + "help.sketch.constraint.parallel": "Két vonalat párhuzamossá tesz. Használja olyan oldalakhoz, amelyeknek párhuzamosnak kell maradniuk, amikor más méretek változnak.", + "help.sketch.constraint.perpendicular": "Két vonalat derékszögben találkoztat. Használja derékszögű sarkokhoz olyan vonalak között, amelyek nem a vázlattengelyeken vannak.", + "help.sketch.constraint.polygon": "Egy zárt vonalláncot szabályos sokszöggé tesz: egyenlő oldalak, minden sarok egy körön. Használja egy kézzel rajzolt sokszög szabályossá tételére és olyannak megtartására.", + "help.sketch.constraint.symmetry": "Két pontot vagy görbét egymás tükörképévé tesz egy vonalra vagy egy pontra nézve. Használja, hogy egy profil szimmetrikus maradjon, amikor az egyik oldalát módosítja.", + "help.sketch.constraint.tangent": "Két görbét érintőlegesen illeszt egymáshoz, például egy vonalat és egy ívet. Használja sima csatlakozásokhoz törés nélkül.", + "help.sketch.construction": "A görbéket normál geometria és szerkesztőgeometria között váltja; a profilok a szerkesztőgeometriát kihagyják. Használja a szerkesztőgörbéket segédvonalként a kiosztáshoz, referenciákhoz és méretekhez.", "help.sketch.construction_line": "Végtelen szerkesztőegyenest hoz létre két ponton át. Használja a vázlat megtervezéséhez a profilok szétvágása nélkül.", "help.sketch.construction_line.angle": "Végtelen szerkesztőegyenest hoz létre a vázlat X tengelyéhez képest adott szögben. Válassza ki a pontot, és adja meg a szöget, vagy válassza ki az irányát.", "help.sketch.construction_line.bisector": "Válasszon ki két egymást metsző egyenes vázlatvonalat, majd bármelyik szögfelezőt. Az eredmény követi a forrásvonalait.", @@ -1412,11 +1564,71 @@ "help.sketch.construction_line.tangent_external": "Válasszon ki egy kört vagy körívet, majd egy külső pontot és egy érintőmegoldást. Az előnézetek csak a megrajzolt íven lévő érintési pontokat mutatják.", "help.sketch.construction_line.tangent_two": "Válasszon ki két kört vagy körívet, majd az előnézetben válasszon közös érintőt. Az érintési pontoknak a megrajzolt görbéken kell lenniük.", "help.sketch.construction_line.vertical": "Végtelen függőleges szerkesztőegyenest hoz létre egy ponton át. Függőleges kényszere a későbbi módosítások után is megmarad.", + "help.sketch.copy": "Másolja a kijelölt vázlatgörbéket és -pontokat a közöttük lévő kényszerekkel és méretekkel együtt. Illessze be őket ebbe a vázlatba vagy egy másikba.", + "help.sketch.create": "Új 2D vázlatot kezd egy síkon vagy a modell egy sík lapján. Minden kihúzás, forgatás és söprés vázlattal kezdődik.", + "help.sketch.cut": "Másolja a kijelölt vázlatgeometriát, és törli a vázlatból, egyetlen visszavonási lépésben. Illessze be máshová ebben a vázlatban vagy egy másikba.", + "help.sketch.dimension": "Vezérlő méretet ad hozzá: hosszt, távolságot, szöget, sugarat vagy átmérőt, attól függően, mit jelöl ki. Írjon be egy értéket vagy egy paraméternevet, és a vázlat követi.", + "help.sketch.edit": "Megnyit egy meglévő vázlatot szerkesztésre. A rá épülő elemek frissülnek, amikor befejezi a vázlatot.", + "help.sketch.ellipse": "Ellipszist rajzol a középpontjából, a nagytengelye végpontjából és a kistengelye felőli egy pontból. Használja ovális nyílásokhoz, bütykökhöz és más elnyújtott kerek alakzatokhoz.", "help.sketch.equation_curve": "Görbét rajzol képletekből: x(t) és y(t), vagy y = f(x) alapján, egy tartományon. A képletek paramétereket is használhatnak, és a görbe követi őket: használja szinuszhullámhoz, spirálhoz vagy fogaskerékfog evolvenséhez.", + "help.sketch.extend": "Meghosszabbít egy vonalat vagy ívet a legközelebbi, útjába eső görbéig, vagy két görbét addig növeszt, amíg találkoznak. Használja egy profil réseinek lezárására vágás vagy szerkesztés után.", + "help.sketch.fillet": "Lekerekíti a két vonal vagy ív találkozásánál lévő sarkot egy adott sugarú ívvel. Az együtt lekerekített több sarok egy sugáron osztozik.", + "help.sketch.finish": "Elhagyja a vázlatot, és visszatér a modellhez. A zárt profiljai ezután készen állnak a Kihúzásra, a Forgatásra és más elemekre.", + "help.sketch.finish_auto_constrain": "Lefuttatja az Automatikus kényszerezést a vázlaton, majd befejezi. Használja, hogy a vázlat egyetlen lépésben teljesen kényszerezett állapotban maradjon.", + "help.sketch.fit_curves_to_section": "Elvág egy háló- vagy szilárd testet a vázlatsíkkal, és vonalakat és íveket illeszt a metszethez. Használja egy szkennelt vagy importált alkatrész szerkeszthető vázlatként való újjáépítésére.", + "help.sketch.include_3d": "A modell éleit, csúcsait és más vázlatok görbéit hozza be ebbe a vázlatba kapcsolt referenciaként, a síkján kívül esőket arra lelapítva. Használja olyan geometriára való építéshez, amely nem a vázlatsíkban fekszik.", + "help.sketch.infinite": "A vonalakat normál vonal és végtelen szerkesztőegyenes között váltja, amely az egész nézeten átfut. Használja őket kiosztási referenciaként, amely sosem ér véget túl korán.", + "help.sketch.insert_dxf": "Beolvassa egy DXF-rajz görbéit egy vázlatba, az Ön által megadott léptékkel, elforgatással és helyen. Használja egy másik programból származó 2D rajzra való építéshez, például lézerrel vágott körvonalhoz.", + "help.sketch.insert_svg": "Beolvassa egy SVG-rajz útvonalait egy vázlatba, az Ön által megadott léptékkel, elforgatással és helyen. Használja egy logó vagy vektoros körvonal kihúzható görbékké alakítására.", + "help.sketch.inspect": "Jelentést nyit a megnyitott vázlatról: hány szabadságfok maradt, és teljesen kényszerezett-e. Használja annak kiderítésére, mi mozdul még, mielőtt befejezi a vázlatot.", + "help.sketch.intersect": "Hozzáadja azokat a görbéket vagy pontokat, ahol testek, lapok, élek vagy más vázlatok metszik a vázlatsíkot. Kapcsoltak maradnak, és követik a modellt, ha az megváltozik.", + "help.sketch.intersection_curve": "3D görbéket hoz létre ott, ahol két test vagy lap találkozik, hozzájuk kapcsolva. Használja őket útvonalként vagy vezérgörbeként, amely követi a modellt.", + "help.sketch.isoparametric_curve": "3D görbét hoz létre egy lap U vagy V iránya mentén egy kiválasztott ponton át, a laphoz kapcsolva. Használja útvonalként vagy osztóvonalként, amely követi a felületet.", + "help.sketch.line": "Összefüggő vonalakat rajzol pontról pontra, nyitott láncként vagy zárt körvonalként. Ez a legtöbb profil alapeszköze.", + "help.sketch.line.midpoint": "Vonalat rajzol a felezőpontjától az egyik végéig, a másik véget tükrözve. Használja egy pontra középen álló vonalakhoz, például szimmetrikus élhez.", + "help.sketch.line3d": "Összefüggő egyeneseket rajzol a térben lévő pontok között, bekapcsolt 3D vázlattal. Használja útvonalakhoz és keretekhez, amelyek elhagyják a vázlatsíkot.", + "help.sketch.mirror": "Tükrözi a vázlatgörbéket és -pontokat egy vonalra vagy tengelyre, és szimmetriakényszereket ad hozzá. Használja egy szimmetrikus profil felének megrajzolására és kiegészítésére.", + "help.sketch.move": "Mozgatja vagy forgatja a vázlatgörbéket és -pontokat, vagy azok másolatát. Használja a geometria áthelyezésére, miközben az megtartja az alakját és a kényszereit.", + "help.sketch.offset": "Másolatot készít egy görbelánc adott távolságban, az egyik vagy mindkét oldalon. Használja falakhoz, körvonalakhoz és hézagokhoz; a távolság szerkeszthető méret marad.", + "help.sketch.paste": "Beilleszti a másolt vagy kivágott vázlatgeometriát az aktív vázlatba. Helyezze el egy pontban, vagy tolja el egy eltolással.", + "help.sketch.pattern.circular": "Másolja a vázlatgeometriát egy középpont körül, teljes körön vagy adott szögön át. Használja csavarkörökhöz, küllőkhöz és fogakhoz.", + "help.sketch.pattern.rectangular": "Másolja a vázlatgeometriát sorokban és oszlopokban, megadott térközökkel. Használja furatok, hornyok vagy szellőzőnyílások rácsaihoz.", + "help.sketch.point": "Vázlatpontot helyez el. Használja referenciaként méretekhez, kényszerekhez, furatközéppontokhoz vagy későbbi elemekhez.", + "help.sketch.point3d": "Pontot helyez el a térben, bekapcsolt 3D vázlattal. Használja referenciaként 3D görbékhez és útvonalakhoz.", + "help.sketch.polygon.circumscribed": "Szabályos sokszöget rajzol egy kör köré, a középponttól egy oldal közepéig mérve. Használja, ha a kulcsnyílás ismert, például egy hatlapú anyánál.", + "help.sketch.polygon.edge": "Szabályos sokszöget rajzol egy oldal két végpontjából. Használja, ha egy oldal hossza és helyzete ismert.", + "help.sketch.polygon.inscribed": "Szabályos sokszöget rajzol egy körbe, a középponttól egy sarokig mérve. Használja, ha a sarkok közötti méret ismert.", + "help.sketch.project": "A modell éleit, lapjait, csúcsait és más vázlatok görbéit hozza be ebbe a vázlatba, a síkjára vetítve. Kapcsoltak maradnak, és követik a modellt, ha az megváltozik.", + "help.sketch.project_to_surface": "Más vázlatok görbéit ennek a vázlatnak a normálisa mentén egy lapra vetíti, kapcsolt 3D görbékként. Használja logó, osztóvonal vagy útvonal görbült felületre helyezésére.", + "help.sketch.rectangle.center": "Téglalapot rajzol a középpontjából és egy sarkából. Használja egy pontra, például az origóra középen álló körvonalakhoz.", + "help.sketch.rectangle.rounded": "Téglalapot rajzol, amelynek mind a négy sarka egy sugárral le van kerekítve. Használja lemezekhez, fedelekhez és lekerekített sarkú nyílásokhoz.", + "help.sketch.rectangle.three_point": "Téglalapot rajzol egy élből és a szemközti oldal egy pontjából. Használja a vázlattengelyekhez képest szögben álló téglalapokhoz.", + "help.sketch.rectangle.two_point": "Téglalapot rajzol két szemközti sarokból. Használja lemezek, tömbök és zsebek körvonalaihoz.", + "help.sketch.scale": "Átléptékezi a vázlatgörbéket és -pontokat egy tényezővel egy alappont körül. Használja egy vázlat egy részének átméretezésére újrarajzolás nélkül.", + "help.sketch.slot.arc_center": "Adott szélességű íves hornyot rajzol egy ív mentén, amelyet a középpontja, a kezdő- és a végpontja határoz meg. Használja forgáspont körüli állítóhornyokhoz.", + "help.sketch.slot.arc_three_point": "Adott szélességű íves hornyot rajzol egy három ponton átmenő ív mentén. Használja, ha a horony pályájának adott pontokon kell áthaladnia.", + "help.sketch.slot.center_point": "Adott szélességű egyenes hornyot rajzol a középpontjától az egyik végív középpontjáig. Használja egy pontra középen álló hornyokhoz.", + "help.sketch.slot.center_to_center": "Adott szélességű egyenes hornyot rajzol a két végívének középpontja között. Használja a középponttól középpontig méretezett hornyokhoz, például csavarok állítóhornyaihoz.", + "help.sketch.slot.overall": "Adott szélességű egyenes hornyot rajzol az egyik külső végétől a másikig. Használja, ha a horony teljes hossza a lényeg.", + "help.sketch.spline.control_point": "Harmadfokú spline-t rajzol, amelyet olyan vezérlőpontok alakítanak, amelyeken nem halad át. Használja sima, folyó görbékhez, amelyeket a pontok mozgatásával alakít.", + "help.sketch.spline.control_point_5": "5. fokú spline-t rajzol, amelyet vezérlőpontok alakítanak. Használja ott, ahol a görbéknek simább görbülettel kell csatlakozniuk, például látható, formatervezett felületeken.", + "help.sketch.spline.fit_point": "Sima spline-t rajzol, amely áthalad minden elhelyezett ponton, nyitottat vagy zártat. Használja egy alak ismert pontokon át való követésére.", + "help.sketch.spline3d": "Sima görbét rajzol a térben pontokon át, bekapcsolt 3D vázlattal. Használja söprési útvonalakhoz és kábelvezetésekhez, amelyek elhagyják a vázlatsíkot.", + "help.sketch.spun_profile": "A vázlatsíkban létrehozza azt a körvonalat, amelyet egy test súrol, amikor egy tengely körül forog. Használja egy esztergált alkatrész körvonalának megszerzésére forgatáshoz vagy rajzhoz.", + "help.sketch.text": "Szöveget ír a vázlatba a választott betűtípussal, magassággal és szöggel; a szavai követhetik a paramétereket. Húzza ki vagy domborítsa címkékhez és feliratokhoz.", + "help.sketch.transform": "Egy lépésben átméretezi, elforgatja és mozgatja a vázlatgeometriát egy középpont körül. Használja egy kijelölés egyszerre történő áthelyezésére és átméretezésére.", + "help.sketch.trim": "Eltávolítja a görbének a kattintás helye körüli legközelebbi keresztezések közötti darabját. Használja az átfedő vonalak zárt profillá tisztítására.", "help.solid.align": "Úgy mozgatja a kijelölt testeket, hogy egy rajtuk lévő pont, lap vagy sík találkozzon máshol lévő ponttal, lappal vagy síkkal. Használja egy alkatrész másikhoz illesztésére az eltolás lemérése nélkül.", + "help.solid.boundary_fill": "Szilárd testeket készít az egymást átfedő szerszámtestek által bezárt tartományokból, csak a kijelölt tartományokat kitöltve. Használja olyan alak építésére, amelyet több test együtt határol.", + "help.solid.bounding_solid": "Dobozt hoz létre, amely körbeveszi a kijelölt testeket, opcionális ráhagyással. Használja egy alkatrész teljes méretének megtekintésére, vagy alapanyagként, amelyből megmunkálják.", + "help.solid.box": "Dobozt hoz létre hosszból, szélességből és magasságból, egy sarokpontból vagy egy pontra középre. Használja gyors tömbhöz, helyőrzőhöz vagy olyan testhez, amelybe elemeket vág.", "help.solid.chamfer": "A kijelölt éleket lapos letöréssel vágja le a megadott távolságban. Használja éles élek tompítására, illetve egy csap vagy csavar bevezetésének megkönnyítésére.", + "help.solid.coil": "Csavarvonalú testet, például rugót hoz létre átmérőből, menetszámból, magasságból vagy emelkedésből, valamint kerek vagy négyzetes keresztmetszetből. Használja rugókhoz, tekercsekhez és csavarvonalú bordákhoz.", "help.solid.combine": "Egyesíti a testeket, kivágja őket egy céltestből, vagy csak a közös térfogatukat tartja meg, és megtarthatja a szerszámtesteket. Használja alkatrészek összeolvasztására, egy alak másikkal való kivágására, vagy két test átfedésének megkeresésére.", + "help.solid.cone": "Kúpot hoz létre egy alapkörből és egy magasságból, vagy csonkakúpot, ha a tetejének is van sugara. Használja kúpos szűkületekhez, tölcsérekhez, süllyesztésekhez és hegyekhez.", + "help.solid.cylinder": "Hengert hoz létre sugárból vagy átmérőből és magasságból, egy ponton állva egy tengely mentén. Használja tengelyekhez, csapokhoz, dudorokhoz és kerek előgyártmányokhoz.", "help.solid.draft": "A kijelölt lapokat a megadott szöggel megdönti, a semleges síktól mérve. Használja fröccsöntött vagy öntött alkatrészeken, hogy kicsússzanak a formából.", + "help.solid.emboss": "A vázlatprofilokat, például szöveget vagy logót kiemel egy lapból, vagy belesüllyeszt. Használja feliratokhoz, címkékhez és jelölésekhez sík vagy kerek lapokon.", "help.solid.extrude": "Egy vázlatprofilt a megadott távolságon át 3D-ssé húz, anyagot adva hozzá vagy eltávolítva. Használja egy lapos körvonal tömbbé, dudorrá vagy mélyedéssé alakítására.", "help.solid.fillet": "A kijelölt éleket a megadott sugarú, sima körív alakú átmenettel lekerekíti. Használja az éles élek lágyítására szilárdság, kényelem vagy megjelenés érdekében.", "help.solid.hole": "A megadott átmérőjű kerek furatot fúr egy lap kiválasztott pontján. Használja csavarok átbújtatására, csapokhoz és más egyszerű kerek nyílásokhoz.", @@ -1425,20 +1637,60 @@ "help.solid.move": "A kijelölt testeket vagy azok másolatát mozgatja vagy forgatja a megadott távolsággal és szöggel. Egy test áthelyezésére vagy egy második példány más helyen való elkészítésére használja.", "help.solid.offset_face": "A kijelölt lapokat a megadott távolsággal befelé vagy kifelé tolja, a környező lapok követik. Fal vastagabbá, zseb mélyebbé vagy dudor magasabbá tételére használja a vázlat szerkesztése nélkül.", "help.solid.pattern.circular": "A kijelölt objektumokat egy tengely körül ismétli a megadott darabszámmal és teljes szöggel. Csavarkörökhöz, küllőkhöz, bordákhoz és fogakhoz használja.", + "help.solid.pattern.path": "Elemeket vagy testeket másol egy vázlatútvonal mentén, darabszám és térköz szerint. Használja görbült él menti furatokhoz vagy sín menti alkatrészekhez.", "help.solid.pattern.rectangular": "A kijelölt objektumokat egy vagy két irányban ismétli a megadott darabszámmal és térközzel. Furatok, bordák, csapok vagy lábak soraihoz és rácsaihoz használja.", + "help.solid.pipe": "Kerek csövet hoz létre vázlatgörbékből álló útvonal mentén, tömören vagy falvastagsággal üregesen. Használja csövekhez, huzalokhoz, kábelekhez és sínekhez.", "help.solid.press_pull": "Anyagot ad hozzá vagy távolít el egy lap húzásakor, illetve lekerekíti az éleket, ha húzza őket. Közvetlenül a modellen végzett gyors módosításokhoz használja.", "help.solid.remove": "A kijelölt testeket az idővonal ezen pontjától kezdve kiveszi a tervből; a korábbi lépések megtartják őket. Segédtest, például egy már nem szükséges vágószerszám elhagyására használja.", "help.solid.replace_face": "A kijelölt sík lapokat egy síkra vagy egy párhuzamos lapra mozgatja, és a test annak megfelelően nő vagy zsugorodik. Egy lap másik alkatrésszel vagy szerkesztő síkkal egy vonalba hozására használja.", "help.solid.revolve": "Egy vázlatprofilt egy tengely körül forgat el, hogy kerek szilárd testet hozzon létre. Tengelyekhez, gombokhoz, tálakhoz és minden olyan alkatrészhez használja, amely egy középvonal körül kerek.", + "help.solid.rib": "Vékony falat ad egy nyitott vázlatgörbe mentén, kitöltve a test következő lapjaiig. Használja falak és sarkok merevítésére, például öntött vagy nyomtatott alkatrészekben.", "help.solid.scale": "A kijelölt testeket egy tényezővel nagyítja vagy kicsinyíti, minden irányban egyformán vagy tengelyenként külön-külön. Importált alak átméretezésére vagy a zsugorodás figyelembevételére használja.", "help.solid.shell": "Kiüregez egy szilárd testet, a megadott vastagságú falakat meghagyva, és a kiválasztott lapokat megnyithatja. Dobozokhoz, fedelekhez és más vékony falú alkatrészekhez használja.", + "help.solid.sphere": "Gömböt hoz létre sugárból vagy átmérőből egy középpont körül. Használja golyókhoz, gombokhoz és lekerekített végekhez.", "help.solid.split_body": "Egy testet külön testekre vág egy síkkal, egy másik test lapjával vagy egy vázlatvonallal. Önállóan nyomtatott, megmunkált vagy színezett alkatrészek készítéséhez használja.", "help.solid.split_face": "A kijelölt lapokat egy sík, egy másik test lapja vagy vázlatgörbék mentén osztja fel, a test elvágása nélkül. Egy lap egy részének saját dőlés, szín vagy eltolás adásához használja.", "help.solid.sweep": "Egy vázlatprofilt egy útvonal mentén húz végig, hogy szilárd testet hozzon létre. Csövekhez, sínekhez, fogantyúkhoz és minden olyan alkatrészhez használja, amelynek állandó a keresztmetszete, és egy görbét követ.", + "help.solid.thread": "ISO metrikus menetet ad egy hengeres laphoz, kozmetikus menetként vagy a testbe vágva. Használja csavarokhoz, menetes furatokhoz és nyomtatott csavaralkatrészekhez.", + "help.solid.torus": "Gyűrűt hoz létre a tengelye körüli nagy sugárból és a kerek keresztmetszetének kis sugarából. Használja O-gyűrűkhöz, fogantyúkhoz és kerek gyöngyökhöz.", + "help.solid.web": "Vékony falat ad minden nyitott vázlatgörbe mentén, mindegyiket a test következő lapjaiig kitöltve. Használja házakban és tálcákban támasztó falak rácsához.", + "help.surface.blend": "Olyan felületet hoz létre, amely áthidalja két test éleit, mindkettőhöz érintő vagy görbületfolytonos módon. Használja két felület sima összekötésére egy résen át.", + "help.surface.boundary": "Felületet hoz létre, amely kitölt két-négy vázlatgörbéből álló hurkot vagy egy görbehálót. Használja szabadformájú panelekhez, amelyeket az éleik határoznak meg.", + "help.surface.extend": "Meghosszabbít egy felületet az élein túl, adott távolsággal vagy egy síkig vagy lapig. Használja, hogy a felületek átfedjék egymást, mielőtt levágja vagy összevarrja őket.", + "help.surface.extrude": "Vázlatgörbéket vagy profilokat tol egy távolságon át felületté, csak falakkal, fedőlapok nélkül. Használja vékony falakhoz, valamint vágó- vagy osztószerszámokhoz.", + "help.surface.fair": "Kisimítja a szabadformájú lapok belsejét, miközben az éleik a helyükön maradnak. Használja egy felület dudorainak és hullámos tükröződéseinek kiegyenlítésére.", + "help.surface.loft": "Olyan felületet hoz létre, amely sorban áthalad két vagy több szelvényen, fedőlapok nélkül. Használja héjakhoz és különböző alakzatok közötti átmenetekhez.", + "help.surface.match": "Újraépíti egy felület szélét, hogy találkozzon egy másik test élével, egyező érintővel vagy görbülettel. Használja két felület közötti varrat sima lezárására.", + "help.surface.offset": "Lapok másolatát hozza létre a normálisaik mentén adott távolságban, új felülettestként. Használja burkolatokhoz, hézagokhoz vagy egy fal másik oldalához.", + "help.surface.patch": "Egy vázlattartományt vagy élek zárt hurkát tölt ki egy lappal, laposan vagy simán görbítve. Használja egy felülettest lyukainak lezárására.", + "help.surface.reverse_normal": "Megfordítja egy felülettest lapjait, így a külső oldaluk a másik irányba néz. Használja vastagítás vagy összevarrás előtt, ha a lapok befelé néznek.", + "help.surface.revolve": "Vázlatgörbéket vagy profilokat forgat egy tengely körül felületté, fedőlapok nélkül. Használja kerek héjakhoz, például lámpaernyőkhöz, tálakhoz és fúvókákhoz.", + "help.surface.ruled": "Egyenes vonalakból álló sávot hoz létre élek láncából kiindulva, a lap normálisa vagy érintője mentén, vagy szögben. Használja dőlt falakhoz, lefutásokhoz és osztófelületekhez.", + "help.surface.stitch": "Összevarrja a felülettesteket a közös éleik mentén, szilárd testté, ha zárt teret alkotnak. Használja felületek egy csoportjának egyetlen testté alakítására.", + "help.surface.sweep": "Vázlatgörbéket vagy profilokat mozgat egy útvonal mentén felületté, fedőlapok nélkül. Használja ereszcsatornákhoz, szegélyekhez és más vékony alakzatokhoz, amelyek egy útvonalat követnek.", + "help.surface.thicken": "Vastagságot ad egy felülettestnek, szilárd testté alakítva. Használja felületekből nyomtatható vagy megmunkálható alkatrészek készítésére.", + "help.surface.trim": "Elvág egy felületet egy síkkal, egy másik felülettel vagy vázlatgörbékkel, és a kiválasztott oldalt tartja meg. Használja felületek alakra vágására az összevarrás előtt.", + "help.surface.unstitch": "Egy test lapjait különálló felülettestekbe választja szét; egy szilárd test felületekre esik szét. Használja egy test egyes lapjainak átdolgozására.", + "help.surface.untrim": "Eltávolítja a lyukakat egy lapról, vagy visszaállítja a külső határát a felület természetes határaira. Használja a teljes felület visszanyerésére vágás után.", "help.third_party": "Harmadik felek licencei", "help.timeline.compute_all": "Az idővonal minden elemét az elejétől újraépíti. A modell frissítésére és a sikertelen elemek megtekintésére használja.", "help.timeline.delete": "Törli a kijelölt elemeket az idővonalról; egy vázlat törlése a belőle épített elemeket is törli. Egy már nem kívánt lépés eltávolítására használja.", + "help.timeline.dependents": "Felsorolja azokat az elemeket, amelyek egy elemmel együtt törlődnének, és azokat, amelyek nélküle hibára futnának. Használja egy elem törlése vagy módosítása előtt.", "help.timeline.play": "Egyenként újrajátssza a hátralévő elemeket, minden újraépítés után fél másodperc szünettel. A leállításhoz futtassa újra ezt a parancsot, vagy nyomja meg az Esc billentyűt; az Ugrás a végére azonnal a végére ugrik.", + "help.view.apply": "A nézetet egy szabványos irányba fordítja, például elölről, felülről vagy izometrikusra, vagy egy mentett elnevezett nézetre. Használja, hogy a modellt rögzített szögből nézze.", + "help.view.delete": "Töröl egy mentett elnevezett nézetet. A modell nem változik.", + "help.view.fit": "Nagyít és pásztáz, hogy az egész modell vagy a kijelölés kitöltse a nézetet. Használja, ha a modell kikerült a látómezőből.", + "help.view.grid": "Megjeleníti vagy elrejti a rácsot a nézetablakban. Használja méret- és tájékozódási segédletként modellezés közben.", + "help.view.ground_shadow": "Megjeleníti vagy elrejti a modell árnyékát a talajon. Használja a magasság és a helyzet könnyebb megítéléséhez.", + "help.view.home": "Visszafordítja a nézetet a kezdőirányba, és illeszti a modellt. Használja egy ismert nézethez való gyors visszatéréshez.", + "help.view.list": "Felsorolja a tervben mentett elnevezett nézeteket. Használja egy korábban mentett nézet megkeresésére.", + "help.view.navigate": "Lépésenként forgatja, pásztázza vagy nagyítja a nézetet, például negyedfordulatot balra. Használja a kamera pontos mozgatására húzás helyett.", + "help.view.navigation_bar": "Megjeleníti vagy elrejti a navigációs sávot a nézetablak alján. Rejtse el a több helyért, jelenítse meg, hogy egérrel elérje a nézeteszközöket.", + "help.view.rename": "Új nevet ad egy mentett elnevezett nézetnek. Maga a nézet változatlan marad.", + "help.view.save": "Elmenti a kamera jelenlegi helyzetét elnevezett nézetként a tervben. Használja, hogy később ugyanahhoz a szöghöz térjen vissza, például képernyőképekhez.", + "help.view.spin": "Folyamatosan körbeforgatja a nézetet a modell körül a következő kattintásig vagy billentyűleütésig. Használja egy modell minden oldalról való bemutatására.", + "help.view.tool": "Rögzít egy kameraeszközt, keringést, pásztázást vagy nagyítást, így a bal egérgombbal való húzás mozgatja a nézetet, amíg ki nem kapcsolja. Használja érintőpadon vagy középső egérgomb nélkül.", + "help.view.visual_style": "Átváltja a modell megjelenítési módját: árnyalt élekkel vagy anélkül, drótváz, vagy rejtett élekkel. Használja a drótvázat a belső megtekintésére, az árnyaltat a tiszta megjelenéshez.", "history.sketch_under_constrained": "Alulkényszerezett ({count} szabadságfok)", "history.sketch_under_constrained_one": "Alulkényszerezett ({count} szabadságfok)", "hole_presets.confirm_delete": "Törlés megerősítése", @@ -1615,6 +1867,10 @@ "material.selection.mixed": "Különböző anyagok", "measure.at": "Itt", "measure.item": "{number}. elem", + "measure.quick_copied": "Mérés átmásolva", + "measure.quick_copy": "Kattintson a mérés másolásához", + "measure.quick_edges": "{count} él", + "measure.quick_faces": "{count} lap", "messages.ago_h": "{n} órája", "messages.ago_min": "{n} perce", "messages.ago_s": "{n} másodperce", @@ -1767,6 +2023,11 @@ "not a move": "nem mozgatás", "not a number": "nem szám", "open a sketch first": "előbb nyisson meg egy vázlatot", + "parameters.apply_live": "Minden módosítás alkalmazása a mező elhagyásakor", + "parameters.apply_live_tip": "Kikapcsolva: a módosítások az Alkalmaz vagy az OK megnyomásáig várnak, majd a terv egyszer épül újra mindegyikkel. A Mégse vagy az Esc elveti őket.", + "parameters.held": "{count} módosítás vár az Alkalmazásra", + "parameters.held_hint": "Kifejezések és egységek szerkesztése. A módosítások az Alkalmaz vagy az OK megnyomásáig várnak.", + "parameters.held_one": "1 módosítás vár az Alkalmazásra", "parts.automatic_name": "automatikus", "parts.dimension.across_flats": "Kulcsnyílás", "parts.dimension.bore": "Furat", @@ -1789,6 +2050,8 @@ "parts.family.set_screw": "Belső kulcsnyílású szárnélküli csavar, lapos végű", "parts.family.socket_head_cap_screw": "Belső kulcsnyílású hengeresfejű csavar", "parts.family.washer": "Sima alátét", + "parts.holes": "Furatok (opcionális)", + "parts.placement_hint": "jelöljön ki egy vagy több furatot, hogy mindegyikbe beillesszen egy alkatrészt, vagy az OK az origóba helyezi", "pattern.alignment_2": "2. igazítás", "pattern.alignment_hint": "A darabszám tartalmazza az eredetit. A Közép kiegyensúlyozza a másolatokat; a Megfordítás páros összegnél kijelöli a többletoldalt.", "pattern.direction_1": "1. irány", @@ -1955,6 +2218,8 @@ "prefs.origin_plane_names": "Origósíkok nevei", "prefs.origin_plane_names_note": "A Böngészőben, a párbeszédablakokban és a listákban megjelenő nevek. A {top} az XY, a {front} az XZ, a {right} pedig az YZ, a Z felfelé mutat. A parancsok, szkriptek és mentett tervek az XY, XZ és YZ jelölést tartják meg.", "prefs.overall_speed": "Összsebesség", + "prefs.parameter_list_rows": "Paraméterlista sorai", + "prefs.parameter_list_rows_tip": "Az üres értékmezőben a Lefelé nyíl felsorolja a paramétereket, a kedvenceket előre, legfeljebb ennyit. Kevesebb sor esetén a lista könnyű marad egy nagy tervben.", "prefs.persistent_ribbon_pressed": "Az aktív menüszalag-parancsok kiemelése", "prefs.persistent_ribbon_pressed_help": "Kiemeli a menüszalag parancsait, amíg a hatásuk be van kapcsolva. Kapcsolja ki a korábbi semleges megjelenés és parancssúgó megtartásához.", "prefs.perspective_fov": "Látószög", @@ -1984,6 +2249,8 @@ "prefs.quality_fastest": "leggyorsabb", "prefs.quality_slowest": "leglassabb", "prefs.quality_smoother": "simább", + "prefs.quick_measure": "Gyorsmérés a nézet sarkában", + "prefs.quick_measure_note": "A kijelölés mérete a nézet jobb alsó sarkában: egy él hossza, kerek él esetén Ø és R, egy lap területe, egy pont helyzete, két elem távolsága és szöge, több él vagy lap összege. Egy kattintás átmásolja.", "prefs.render_previous_geometry_budget": "Korábbi geometriakeret használata a renderelési előnézethez", "prefs.render_previous_geometry_budget_help": "A korábbi rögzített geometriakorlát visszaállítása (64 bites asztali gépen 512 MiB, máshol 128 MiB) az eszközalapú keret helyett.", "prefs.reports_saved": "{n} mentve", @@ -2029,6 +2296,8 @@ "prefs.sketch_tab_always_note": "A Vázlat fül a szalagon marad, közvetlenül a Modell után, akkor is, ha nincs megnyitott vázlat. Rajzszerszámai ilyenkor a kijelölt síkon vagy lapon, illetve a következőként kiválasztott síkon indítanak vázlatot, benne a kiválasztott szerszámmal.", "prefs.sketch_tracking_guides": "Középpont- és felezőpont-igazítási segédvonalak", "prefs.sketch_tracking_guides_note": "Vigye az egérmutatót egy kör középpontja vagy egy egyenes él felezőpontja fölé, majd mozgassa vízszintesen vagy függőlegesen, hogy ideiglenes pontozott segédvonal jelenjen meg.", + "prefs.sketch_tracking_points": "Igazítási segédvonalak végpontokból és sarkokból rajzolás közben", + "prefs.sketch_tracking_points_note": "Amíg egy rajzeszköz pontra vár, egy vonal vagy ív végpontja, egy téglalap sarka vagy egy szabad pont fölé víve ugyanaz az ideiglenes pontozott segédvonal jelenik meg, mint egy középpontnál vagy felezőpontnál. Kikapcsolva: csak a középpontoknál és felezőpontoknál.", "prefs.sketching": "Vázlatkészítés", "prefs.snap_drawing_note": "Vázlatpont rajzolása vagy húzása közben.", "prefs.source": "Forrás", @@ -2056,6 +2325,16 @@ "prefs.theme_follow_off": "Ki", "prefs.theme_follow_stop": "Követés leállítása", "prefs.theme_import_hint": "Kattintson egy témafájl kiválasztásához, húzzon ide egyet, vagy nyomja meg a Ctrl+V (Macen Cmd+V) billentyűkombinációt egy kimásolt témával", + "prefs.theme_option_opacity": "Átlátszatlanság", + "prefs.theme_option_opacity_help": "A lebegő panelek átlátszatlanságának beállítása. Az olvashatósági alsó határ védi a szöveget a nézetablak színei fölött.", + "prefs.theme_option_tab_colours": "Fülek színei", + "prefs.theme_option_tab_colours_help": "A téma panel- és nézetablak-színeinek használata minden menüszalag-fülhöz.", + "prefs.theme_option_tab_fade_ms": "Fülváltás átmenete", + "prefs.theme_option_tab_fade_ms_help": "A panel- és nézetablak-színek áttűnése fülváltáskor. A nulla azonnal vált.", + "prefs.theme_options_badge": "Beállítások", + "prefs.theme_options_more": "Továbbiak · {count}", + "prefs.theme_options_reset": "Visszaállítás", + "prefs.theme_options_unavailable": "Ezen az eszközön nem érhető el", "prefs.theme_paste": "Téma beillesztése", "prefs.theme_paste_none": "A vágólap nem tartalmaz SolidCraft-témát", "prefs.timeline_half_hour": "−30 perc", @@ -2077,6 +2356,8 @@ "prefs.under_cursor": "A kurzor alatt", "prefs.units_precision": "Egységek és pontosság", "prefs.updates": "Frissítések", + "prefs.value_boxes_in_view": "Értékek megjelenítése a fogantyúik mellett a nézetben", + "prefs.value_boxes_in_view_tip": "A parancs értéke (Távolság, Sugár…) egy mezőben a fogantyúja mellett, a modell fölött. Kikapcsolva: a parancsablakban írja be, amely kijelölés után átveszi a billentyűzetet, és semmi sem takarja a modellt.", "prefs.view": "Nézet viselkedése", "prefs.view_confirm_corner": "OK és Mégse a nézet sarkában", "prefs.view_confirm_corner_note": "Amíg egy párbeszédablak vagy vázlat nyitva van, a nézet jobb felső részén egy pipa és egy kereszt a párbeszédablak OK és Mégse gombját működteti, illetve befejezi vagy megszakítja a vázlatot. A nézetnavigátor lejjebb kerül alattuk.", @@ -2086,6 +2367,12 @@ "prefs.viewport_background": "Háttér", "prefs.viewport_background_colour": "Szín", "prefs.viewport_background_theme": "A téma színátmenete", + "prefs.viewport_section_status": "Nézetablak-metszet állapotának megjelenítése", + "prefs.viewport_section_status_help": "A metszet beállításának megjelenítése a nézetablakban. Az Esc és az aktív menüszalag-gomb kikapcsolja. Kapcsolja ki az előző metszetvezérlők megtartásához.", + "prefs.viewport_status_details": "Élő nézetablak-részletek", + "prefs.viewport_status_details_help": "Az aktív elemzés és metszet beállításainak szerkesztése szabványos részletkártyákon. Kapcsolja ki a csak olvasható elemzési jelmagyarázatok és a Metszet párbeszédpanel visszaállításához.", + "prefs.viewport_status_summary": "Nézetablak-állapotelemek összevonása", + "prefs.viewport_status_summary_help": "Három vagy több nézetablak-állapotelem összevonása egyetlen összefoglaló jelvénybe. Kapcsolja ki, hogy minden elem a teljes sorban maradjon.", "prefs.wheel": "Görgő", "prefs.wheel_trackpad": "Görgő és érintőpad", "prefs.workspace": "Munkaterület", @@ -2964,7 +3251,9 @@ "render.output_ready": "Készen áll a renderelésre", "render.output_run_check": "Ellenőrzés futtatása", "render.output_size_limit": "{method}: oldalanként legfeljebb {dimension} px és összesen {pixels} képpont. A legnagyobb beleférő méret: {width} × {height}.", + "render.output_size_method_profile": "{width} × {height} rendereléséhez válassza a(z) {method} módot a(z) {profile} CPU-erőforrásprofillal, vagy kisebb méretet.", "render.output_size_ok": "Méret rendben · {width} × {height} px", + "render.output_size_profile": "{width} × {height} rendereléséhez válassza a(z) {profile} CPU-erőforrásprofilt, vagy kisebb méretet.", "render.output_swap_orientation": "Szélesség és magasság felcserélése", "render.output_tools_inspector": "A méretet és a formátumot a Kimenet panelen szerkesztheti.", "render.output_view_image": "Kép megtekintése", @@ -3177,6 +3466,7 @@ "render.size_custom": "Egyéni…", "render.size_full_hd": "Full HD", "render.size_hd": "HD", + "render.size_needs_profile": "{size} · szükséges: {profile}", "render.size_presets": "Méretelőbeállítások", "render.size_presets_pending": "A Kimenet szerkesztővel érkezik", "render.size_preview": "Előnézet", @@ -3464,6 +3754,24 @@ "sheet.flange_type_contour": "Kontúr", "sheet.flange_type_edge": "Él", "sheet.no_body": "nincs lemeztest: kezdje a Perem > Alap paranccsal, vagy alakítson át egy lemezt lemezzé az Átalakítás lemezzé paranccsal (jelölje ki a nagy, sík lapját)", + "sheet.personal_rules.confirm_delete": "Törlés megerősítése", + "sheet.personal_rules.delete": "Törlés", + "sheet.personal_rules.delete_prompt": "Törli ezt a személyes szabályt? A tervekben már meglévő szabályok nem változnak.", + "sheet.personal_rules.full": "A személyes könyvtár már 64 szabályt tartalmaz.", + "sheet.personal_rules.invalid_name": "Adjon meg egy rövid nevet sortörések és vezérlőkarakterek nélkül.", + "sheet.personal_rules.invalid_values": "Egészítse ki a szabály értékeit: mindegyiknek kiértékelhetőnek kell lennie ebben a tervben, pozitív vastagsággal és 0 és 1 közötti K-tényezővel.", + "sheet.personal_rules.missing": "A kijelölt személyes szabály már nem érhető el.", + "sheet.personal_rules.name": "Szabály neve", + "sheet.personal_rules.name_exists": "Már van ilyen nevű személyes szabály. Használjon másik nevet.", + "sheet.personal_rules.name_hint": "Egyedi név", + "sheet.personal_rules.overwrite": "Módosítások mentése a kijelölt személyes szabályba", + "sheet.personal_rules.rename": "Átnevezés", + "sheet.personal_rules.rule": "Személyes szabály", + "sheet.personal_rules.save": "Mentés újként", + "sheet.personal_rules.scope": "A profiljában van elmentve, minden tervhez.", + "sheet.personal_rules.select": "Válasszon szabályt", + "sheet.personal_rules.title": "Személyes szabályok", + "sheet.personal_rules.unsupported": "Ennek a szabálynak olyan értékei vannak, amelyeket ez a verzió nem tud használni.", "shortcut.json_error": "a paramétereknek JSON-nak kell lenniük: {error}", "shortcut.no_command": "nincs `{id}` parancs; a JSON előtti szövegnek parancsazonosítónak kell lennie", "shortcut.pin": "Rögzítés az S doboz tetejére", @@ -3500,6 +3808,7 @@ "sketch_text.align_right": "Jobbra", "sketch_text.bold": "Félkövér", "sketch_text.builtin_font": "{family} (beépített)", + "sketch_text.formula": "Kezdje egyenlőségjellel (=), hogy a szavakat paraméterekből építse fel: ='W' + number(Width)", "sketch_text.italic": "Dőlt", "sketch_text.loading_fonts": "Telepített betűtípusok keresése…", "sketch_text.on_path": "A szöveg követi az útvonalát: a görbe helyezi el", @@ -3640,6 +3949,8 @@ "ui.3d_mouse_section": "3D-egér", "ui.3d_mouse_show_in_view": "A 3D-egér megjelenítése a 3D nézetben", "ui.3d_mouse_show_in_view_tip": "A 3D-egere képe a bal alsó sarokban, amely a mozgatásával együtt mozog.", + "ui.3d_mouse_sketch_orbit": "Vázlatok forgatása a 3D-egérrel", + "ui.3d_mouse_sketch_orbit_tip": "A 3D-egér akkor is forgatja a nézetet, amikor egy vázlat meg van nyitva és merőlegesen látszik, ahogy az egér is tudja. Kikapcsolva: a vázlatot pásztázza és nagyítja, amíg meg nem dönti a nézetet.", "ui.3d_mouse_skip": "Az eszközöm erre nem képes", "ui.3d_mouse_skip_tip": "Mindkét irányban kihagyja ezt a mozgást, és a kalibráció nem veszi figyelembe.", "ui.3d_mouse_speed": "3D-egér sebessége", @@ -3708,6 +4019,7 @@ "ui.agent_chat_auth_terminal": "Futtassa ezt egy terminálban, majd nyomja meg újra a Csatlakozás gombot:", "ui.agent_chat_auth_title": "Bejelentkezés az ügynökbe", "ui.agent_chat_cancel": "Mégse", + "ui.agent_chat_cannot_start": "Telepítve van, de nem indul el: {error}", "ui.agent_chat_changes": "Módosítások: {n}, egy visszavonási lépés", "ui.agent_chat_changes_n": "{n} módosítás", "ui.agent_chat_changes_of": "{n}/{limit} módosítás", @@ -3800,9 +4112,12 @@ "ui.agent_chat_queued_stopped": "Sorban (leállítva, nem küldve)", "ui.agent_chat_refresh": "Frissítés", "ui.agent_chat_refused": "Az ügynöknek nem engedélyezett: {reason}", + "ui.agent_chat_reinstall": "A(z) {agent} telepítve van ({program}), de a SolidCraft ellenőrzésekor nem indult el: {error}\nLehet, hogy a telepítése hiányos, vagy másféle számítógéphez készült. Telepítse újra a következővel: `{install}`, ellenőrizze, hogy egy terminálban kiírja a verzióját, majd nyomja meg a Frissítés gombot.", "ui.agent_chat_remove": "Eltávolítás a sorból", "ui.agent_chat_remove_file": "Levétel erről az üzenetről", "ui.agent_chat_remove_imported": "Levétel erről az üzenetről; az alkatrész a tervben marad (a Visszavonás eltávolítja)", + "ui.agent_chat_reply_cut_off": "A válasz elérte a hosszkorlátot, mielőtt befejeződött volna, ezért hiányos lehet. Kérje meg az ügynököt, hogy folytassa, vagy adjon rövidebb választ.", + "ui.agent_chat_reply_declined": "A modell megtagadta a válaszadást erre az üzenetre.", "ui.agent_chat_retry": "Újrapróbálás", "ui.agent_chat_right": "Jobb", "ui.agent_chat_save": "Mentés", @@ -3861,12 +4176,17 @@ "ui.ai_copy_suffix": "másolat", "ui.ai_custom_url": "Egyéni URL", "ui.ai_default": "Alapértelmezett", + "ui.ai_edit": "Szerkesztés", + "ui.ai_edit_agent_title": "Ügynök szerkesztése", + "ui.ai_edit_gone": "Ez az ügynök el lett távolítva, így nincs mit menteni.", + "ui.ai_edit_tip": "Az ügynök nevének, parancsának vagy végpontjának módosítása. Az elmentett kulcs rejtve marad.", "ui.ai_http_busy": "a végpont foglalt vagy túllépte a korlátját; próbálja később", "ui.ai_http_key": "a végpont elutasította a kulcsot: ellenőrizze a Beállítások > AI menüben", "ui.ai_http_not_found": "nincs ilyen végpont vagy modell: ellenőrizze az alap-URL-t és a modell nevét", "ui.ai_http_too_large": "a beszélgetés túl nagy ehhez a végponthoz; kezdjen új csevegést", "ui.ai_key": "API-kulcs", "ui.ai_key_hint": "opcionális; a rendszer kulcstartójában tárolódik", + "ui.ai_key_keep_hint": "hagyja üresen az elmentett kulcs megtartásához", "ui.ai_key_needs_https": "A kulcs csak HTTPS-en vagy erre a számítógépre kerül elküldésre.", "ui.ai_key_note": "A rendszer kulcstartójában tárolódik, soha nem a SolidCraft beállításaiban. Helyi szervernél hagyja üresen.", "ui.ai_keychain_error": "A rendszer kulcstartója elutasította: {error}", @@ -3898,6 +4218,7 @@ "ui.ai_request_timeout": "Kérés időkorlátja", "ui.ai_request_timeout_hint": "Mennyi ideig tarthat egy modellkérés ehhez a végponthoz, 30 s-tól egy óráig. Lassú helyi modellnél emelje meg.", "ui.ai_reset": "Visszaállítás", + "ui.ai_save": "Mentés", "ui.ai_settings": "AI-beállítások…", "ui.ai_show_features": "AI-funkciók megjelenítése", "ui.ai_show_features_note": "Az ügynök-csevegés: egy panel, ahol egy már használt kódolóügynök vagy egy választott modellvégpont a megnyitott terven dolgozik. Kikapcsolva: nincs AI-gomb, gyorsbillentyű, menüpont vagy panel, és semmi sem keresődik vagy indul el.", @@ -4071,6 +4392,40 @@ "ui.design_properties.project": "Projekt", "ui.design_properties.revision": "Revízió", "ui.design_saved": "Mentve ide: {path}", + "ui.details_access_caption": "A lapok megmutatják, hogy egy szerszám eléri-e őket a kiválasztott irányból.", + "ui.details_angle": "Szög", + "ui.details_continuity_caption": "A kijelölt varrat szerkesztése. A többi varrat megtartja a saját beállításait.", + "ui.details_continuity_stale": "Korábbi geometria: futtassa újra az elemzést a varrat szerkesztéséhez vagy a nézet ráállításához.", + "ui.details_copy_list": "Lista másolása", + "ui.details_curvature": "Görbülettűrés", + "ui.details_curvature_caption": "A lapok színe a helyi görbületi sugarukat mutatja.", + "ui.details_direction": "Szerszámirány", + "ui.details_draft_caption": "A lapok színe a húzási irányhoz mért szögüket mutatja.", + "ui.details_edit_seam": "Varrat szerkesztése", + "ui.details_fixture": "Komponensminta – csak mintaadatok", + "ui.details_fixture_caption": "Komponensminta szemléltető értékekkel; ez nem modelleredmény.", + "ui.details_fixture_problem": "Példa megállapítás hibával", + "ui.details_fixture_readout": "Példa mért érték", + "ui.details_flip": "Megfordítás", + "ui.details_gap": "Rés", + "ui.details_hatch": "Vágott lapok sraffozása", + "ui.details_jump": "Görbületugrás", + "ui.details_key": "Jelmagyarázat", + "ui.details_offset": "Eltolás", + "ui.details_plane": "Sík", + "ui.details_pull": "Húzási irány", + "ui.details_radius": "Sugár", + "ui.details_readout": "Leolvasás", + "ui.details_samples": "Minták", + "ui.details_seam": "{body} · {edge}. él", + "ui.details_settings": "Beállítások", + "ui.details_sharp_gap": "Éles vagy rés", + "ui.details_show_all": "Mind a(z) {count} megjelenítése", + "ui.details_smooth": "Sima", + "ui.details_stripes": "Csíkok", + "ui.details_target": "Geometriára állítás", + "ui.details_worst_seams": "Legrosszabb varratok", + "ui.details_zebra_caption": "A tükröződő csíkok megmutatják, milyen simán csatlakoznak a lapok.", "ui.diameter": "Átmérő", "ui.dimension": "Méret", "ui.direct_interaction": "Közvetlen interakció", @@ -4131,6 +4486,7 @@ "ui.equation_y_of_x": "y(x)", "ui.equivalent_radius": "Egyenértékű sugár", "ui.expand": "Kibontás", + "ui.explode_needs_two_components": "A Robbantott nézet széthúzza a komponenseket, ezért a tervben legalább kettő kell belőlük (Új komponens, Terv beszúrása vagy Komponensek létrehozása testekből)", "ui.exportDxf": "DXF exportálása…", "ui.export_ellipsis_keymap": "Exportálás…", "ui.export_keymap_tip": "Billentyűk, S doboz kedvencei és az eszköztár, fájlként", @@ -4419,12 +4775,17 @@ "ui.plugins_skipped": "{count} mappa nem olvasható.", "ui.plugins_version": "{version}", "ui.point": "Pont", + "ui.point_cloud_add_filter": "Szűrő hozzáadása…", "ui.point_cloud_auto_range": "Automatikus tartomány", "ui.point_cloud_color_by": "Színezés alapja", "ui.point_cloud_file_colors": "Fájl színei", + "ui.point_cloud_filter_by": "Szűrés alapja", "ui.point_cloud_height_high": "Legmagasabb", "ui.point_cloud_height_low": "Legalacsonyabb", + "ui.point_cloud_legend_filter": "A fenti jelmagyarázatban megjelenítve és elrejtve", "ui.point_cloud_no_file_colors": "A fájlnak nincsenek színei", + "ui.point_cloud_pause": "Szünet", + "ui.point_cloud_play": "Lejátszás", "ui.point_cloud_single_color": "Egyetlen szín", "ui.point_cloud_size": "Pontméret", "ui.point_cloud_units": "Fájl egységei", @@ -4453,6 +4814,7 @@ "ui.properties.inertia_y": "Iyx, Iyy, Iyz", "ui.properties.inertia_z": "Izx, Izy, Izz", "ui.properties.measuring": "Mérés…", + "ui.properties.measuring_bodies": "Mérés… {done} / {total} test", "ui.properties.measuring_failed": "A mérés leállt, mielőtt befejeződött volna.", "ui.properties.principal_axis": "{number}. főtengely", "ui.properties.principal_moments": "Fő tehetetlenségi nyomatékok", @@ -4520,6 +4882,7 @@ "ui.selectOther": "Más kijelölése…", "ui.selectTangentChain": "Érintőlánc kijelölése", "ui.select_all": "Összes kijelölése", + "ui.select_components_by_face": "Komponensek kijelölése (egy lap a komponensét jelöli ki)", "ui.selection": "Kijelölés", "ui.selection_modifiers": "Kijelölési módosítók", "ui.selection_shift_add_only": "Shift+kattintás eltávolítás nélkül hozzáad", @@ -4561,6 +4924,11 @@ "ui.sketch_intersect_selection_filter": "Kijelölési szűrő", "ui.sketch_modifiers": "Vázlatmódosítók", "ui.sketch_project_geometry": "Geometria", + "ui.sketch_report.all_constrained": "Minden teljesen kényszerezett.", + "ui.sketch_report.dof": "Szabadságfokok", + "ui.sketch_report.loose": "Nem teljesen kényszerezett", + "ui.sketch_report.status": "Állapot", + "ui.sketch_report.title": "Vázlatjelentés", "ui.sketch_scale": "Vázlat léptéke", "ui.sketches": "Vázlatok", "ui.slice": "Szeletelés", @@ -4670,7 +5038,14 @@ "ui.viewport_status_key": "Jelmagyarázat megjelenítése: {name}", "ui.viewport_status_minimum_radius": "Minimális sugár", "ui.viewport_status_pairs": "{count} pár", + "ui.viewport_status_section_hatch": "{setting} · sraffozás", + "ui.viewport_status_section_plane": "Aktuális sík", + "ui.viewport_status_section_tilted": "megdöntve · {offset}", "ui.viewport_status_stripes": "{count} csík", + "ui.viewport_status_summary_clear": "Minden kikapcsolása", + "ui.viewport_status_summary_heading": "Ebben a nézetben", + "ui.viewport_status_summary_label": "{name} és még {count} elem", + "ui.viewport_status_summary_more": "+{count}", "ui.viewport_theme_follow": "Téma követése", "ui.visual_shaded_hidden_edges": "Árnyalt rejtett élekkel", "ui.visual_shaded_with_edges": "Árnyalt élekkel", diff --git a/assets/locales/id.json b/assets/locales/id.json index 3b396007a2..e6fb8510ed 100644 --- a/assets/locales/id.json +++ b/assets/locales/id.json @@ -60,6 +60,7 @@ "BLEND SURFACE": "PERMUKAAN BLEND", "BOUNDARY SURFACE": "PERMUKAAN BATAS", "BROWSER": "PENJELAJAH", + "Back to the original order": "Kembali ke urutan asli", "Ball": "Bola", "Bar": "Batang", "Base Center": "Pusat Dasar", @@ -595,6 +596,8 @@ "Snap to face and edge centres": "Snap ke pusat muka dan tepi", "SolidCraft cannot submit reports directly. Submit on GitHub; no token is needed in the app.": "SolidCraft tidak dapat mengirim laporan secara langsung. Kirim di GitHub; token tidak diperlukan di aplikasi.", "SolidCraft closed before these designs were saved. Their last autosave can be opened.": "SolidCraft tertutup sebelum desain ini disimpan. Simpan otomatis terakhirnya dapat dibuka.", + "Sort A to Z": "Urutkan A ke Z", + "Sort Z to A": "Urutkan Z ke A", "Spacing 2 must be a non-zero number": "Jarak antar 2 harus berupa bilangan bukan nol", "Spacing must be a non-zero number": "Jarak antar harus berupa bilangan bukan nol", "Specialise": "Spesialisasikan", @@ -819,6 +822,7 @@ "cmd.appearance_pick_up": "Ambil Tampilan", "cmd.appearance_replace": "Ganti Tampilan", "cmd.assembly_bom": "Daftar Material (BOM)", + "cmd.assembly_mass_properties": "Properti massa", "cmd.body.print_face": "Permukaan cetak", "cmd.body_rename": "Ganti Nama Bodi", "cmd.browser_collapse_all": "Ciutkan Semua", @@ -1244,6 +1248,7 @@ "cmd.view.render_browser": "Penjelajah Render", "cmd.view.render_dock": "Pindah Dok Render", "cmd.view.render_focus": "Tampilan Fokus Render", + "cmd.view.status_details": "Detail Status Viewport", "cmd.view.status_items": "Item Status Viewport", "cmd.view_ambient_occlusion": "Ambient Occlusion", "cmd.view_angle_readout": "Sudut Tampilan", @@ -1387,21 +1392,168 @@ "from the rule": "dari aturan", "help.apache": "Lisensi Apache 2.0", "help.appearance.assign": "Memberi bodi, muka, atau komponen terpilih warna atau tampilan material, tanpa mengubah bobotnya. Gunakan untuk membedakan part atau melihat pratinjau hasil akhir.", + "help.assembly.bom": "Mencantumkan part dalam desain atau komponen beserta jumlahnya, menurut nomor part, dan dapat menyimpan daftar itu sebagai CSV atau JSON. Gunakan sebagai daftar part untuk pemesanan dan perakitan.", "help.attributions": "Atribusi (ATTRIBUTION.md)", "help.build": "Versi {version} · commit {commit} · dibangun {date}", + "help.canvas.decal": "Menempatkan gambar pada muka datar model, seperti stiker. Gunakan untuk menampilkan label, logo, atau pola cetak pada sebuah part.", + "help.canvas.edit": "Mengubah sebuah kanvas: posisi, ukuran, sudut, opasitas, pencerminan, visibilitas, atau namanya. Gunakan untuk menyelaraskan gambar referensi dengan sketsa yang Anda jiplak di atasnya.", + "help.canvas.insert": "Menempatkan gambar PNG atau JPEG pada bidang atau muka sebagai referensi tembus pandang. Gunakan untuk menjiplak foto atau gambar di dalam sketsa, dengan gambar diskalakan ke ukuran yang diketahui.", + "help.component.break_link": "Memutus komponen tertaut dari file desainnya, dengan bodinya dibiarkan apa adanya. Gunakan bila part tidak perlu lagi mengikuti perubahan pada file aslinya.", + "help.component.capture_position": "Mempertahankan posisi part yang Anda pindahkan secara manual, sehingga posisi itu menjadi letak part tersebut. Gunakan setelah menyeret part ke tempatnya yang tidak ditahan sambungan apa pun.", + "help.component.create": "Menambahkan komponen baru yang kosong ke desain, di dalam komponen aktif kecuali Anda memilih induk lain. Gunakan untuk memberi bagian baru dari rakitan tempatnya sendiri bagi bodi dan sketsa.", "help.component.finish_edit": "Menyimpan part yang sedang diedit di tempat, kembali ke rakitannya, dan memperbarui rakitan dari part tersebut. Gunakan setelah perubahan Anda pada part tertaut selesai.", + "help.component.from_bodies": "Mengubah setiap bodi terpilih menjadi komponen tersendiri, dinamai sesuai bodinya. Gunakan bila desain yang dibangun dari bodi lepas perlu menjadi rakitan yang part-nya dapat disambung dan dipindahkan.", + "help.component.insert_design": "Menyisipkan desain tersimpan lain sebagai komponen yang tertaut ke filenya, dengan bodi solidnya ditempatkan seperti di sana. Gunakan untuk memakai ulang part dalam rakitan dan menerima perubahan berikutnya lewat Perbarui Tautan.", + "help.component.properties": "Menampilkan volume, massa, pusat massa, dan momen inersia sebuah komponen, berdasarkan material bodinya. Gunakan untuk memeriksa bobot dan keseimbangan sebuah rakitan.", + "help.component.revert_position": "Mengembalikan part yang Anda pindahkan secara manual ke tempat semula, dengan membuang perpindahan yang tidak ditangkap. Gunakan untuk membatalkan percobaan pemindahan dalam rakitan.", + "help.component.update_links": "Membaca ulang file desain tertaut dan mengganti bodi yang berubah. Gunakan setelah mengedit part yang dipakai rakitan ini.", + "help.config.configure": "Menambahkan parameter, fitur, material, atau tampilan sebagai kolom tabel konfigurasi. Gunakan untuk membuat varian dari satu desain, seperti berbagai ukuran sebuah part.", + "help.config.preview": "Memeriksa bahwa satu baris tabel konfigurasi dapat dibangun ulang, tanpa mengubah desain atau riwayatnya. Gunakan sebelum beralih ke varian yang belum Anda yakini.", + "help.config.rules": "Menampilkan aturan tabel konfigurasi, barisnya, dan baris mana yang aktif. Gunakan untuk melihat bagaimana varian sebuah desain diatur.", + "help.config.table": "Menampilkan tabel konfigurasi: satu baris per varian, satu kolom per parameter atau fitur, dan apakah desain masih cocok dengan baris aktif. Gunakan untuk meninjau varian sebuah desain.", "help.connect_note": "Mulai ulang setiap asisten untuk memuat server SolidCraft. Setiap file dicadangkan sebagai .bak sebelum diubah.", "help.connect_title": "Hubungkan asisten AI", + "help.construct.axis.edge": "Membuat sumbu konstruksi sepanjang tepi lurus atau garis sketsa. Gunakan sebagai sumbu untuk revolve, pola melingkar, atau sambungan.", + "help.construct.axis.normal_to_face": "Membuat sumbu konstruksi melalui sebuah titik, tegak lurus terhadap sebuah muka. Gunakan sebagai sumbu yang menjulur lurus keluar dari permukaan.", + "help.construct.axis.through_cylinder": "Membuat sumbu konstruksi melalui pusat muka silinder, kerucut, atau torus, atau tepi melingkar. Gunakan untuk memutar fitur atau membuat polanya mengelilingi part bulat.", + "help.construct.axis.two_planes": "Membuat sumbu konstruksi di tempat dua bidang atau muka datar bertemu. Gunakan bila sumbu yang Anda perlukan adalah garis di antara dua muka.", + "help.construct.axis.two_points": "Membuat sumbu konstruksi melalui dua titik, titik sudut, atau titik sketsa. Gunakan untuk sumbu yang membentang di antara dua posisi yang diketahui.", + "help.construct.plane.along_path": "Membuat bidang konstruksi tegak lurus terhadap sebuah tepi atau kurva, pada suatu posisi di sepanjangnya. Gunakan untuk menggambar sketsa profil bagi sweep atau penampang sebuah jalur.", + "help.construct.plane.at_angle": "Membuat bidang konstruksi yang diputar dengan suatu sudut dari sebuah bidang atau muka, terhadap sebuah sumbu atau tepi. Gunakan untuk sketsa miring, seperti braket bersudut.", + "help.construct.plane.midplane": "Membuat bidang konstruksi tepat di tengah antara dua bidang atau muka datar, atau membagi dua keduanya di tempat mereka bertemu pada suatu sudut. Gunakan sebagai bidang simetri untuk pencerminan.", + "help.construct.plane.offset": "Membuat bidang konstruksi yang sejajar dengan sebuah bidang atau muka datar, pada suatu jarak atau melalui sebuah titik. Gunakan untuk menggambar sketsa pada ketinggian tertentu di atas sebuah muka.", + "help.construct.plane.perpendicular": "Membuat bidang konstruksi yang memuat sebuah garis atau tepi dan berdiri tegak lurus terhadap sebuah bidang atau muka. Gunakan untuk menggambar sketsa tegak pada model sepanjang sebuah tepi.", + "help.construct.plane.tangent": "Membuat bidang konstruksi yang menyentuh silinder, kerucut, bola, atau torus pada sebuah titik. Gunakan untuk menggambar sketsa di sisi part bulat.", + "help.construct.plane.three_points": "Membuat bidang konstruksi melalui tiga titik. Gunakan untuk bidang yang ditentukan oleh posisi pada model, seperti tiga sudut.", + "help.construct.plane.two_edges": "Membuat bidang konstruksi melalui dua tepi lurus atau garis yang terletak pada satu bidang. Gunakan untuk bidang yang melintasi sebuah part, misalnya melalui dua tepi yang berseberangan.", + "help.construct.point.along_path": "Membuat titik konstruksi pada suatu posisi di sepanjang tepi atau kurva. Gunakan sebagai referensi di suatu titik pada jalur.", + "help.construct.point.center": "Membuat titik konstruksi di pusat tepi melingkar, lingkaran atau busur sketsa, bola, atau torus. Gunakan sebagai referensi untuk lubang, sumbu, dan sambungan.", + "help.construct.point.edge_plane": "Membuat titik konstruksi di tempat sebuah tepi atau garis menembus sebuah bidang atau muka datar. Gunakan untuk menandai tempat sebuah jalur memotong bidang.", + "help.construct.point.three_planes": "Membuat titik konstruksi di tempat tiga bidang atau muka datar bertemu. Gunakan sebagai referensi sudut, bahkan bila model tidak memiliki titik sudut di sana.", + "help.construct.point.two_edges": "Membuat titik konstruksi di tempat dua tepi atau garis bertemu, atau paling berdekatan. Gunakan sebagai referensi pada sudut virtual.", + "help.construct.point.vertex": "Membuat titik konstruksi pada titik sudut atau titik sketsa. Gunakan sebagai referensi yang tetap ada saat sketsa atau bodi di sekitarnya disembunyikan.", + "help.contact.check": "Mencantumkan pasangan part dalam set kontak yang saling menembus pada posisinya saat ini. Gunakan untuk menemukan tabrakan sebelum Anda menggerakkan sambungan atau menjalankan studi gerak.", + "help.contact.create": "Mengelompokkan dua part atau lebih ke dalam satu set kontak, yang bodinya dijaga agar tidak saling menembus saat sambungan bergerak. Gunakan untuk part yang harus berhenti saat bersentuhan, seperti tuas terhadap penahannya.", + "help.contact.disable_all": "Mematikan kontak, sehingga sambungan yang digerakkan dan studi gerak tidak lagi berhenti di tempat part bersentuhan. Gunakan untuk menggerakkan part dengan bebas lagi, atau agar rakitan besar tetap responsif.", + "help.contact.edit": "Mengubah sebuah set kontak: part yang dimuatnya, namanya, atau apakah ditekan; set juga dapat dihapus. Gunakan untuk menyesuaikan part mana yang dijaga agar tidak saling menembus.", + "help.contact.enable_all": "Memasukkan setiap part rakitan ke dalam satu set kontak, sehingga tidak ada dua part yang dapat saling menembus saat sambungan bergerak. Gunakan untuk pemeriksaan cepat seluruh mekanisme.", + "help.contact.enable_sets": "Menyalakan kontak untuk set kontak yang Anda definisikan, sehingga sambungan yang digerakkan dan studi gerak berhenti di tempat part-nya akan saling menembus. Gunakan untuk mengembalikan set Anda setelah kontak dimatikan.", + "help.contact.list": "Mencantumkan set kontak dalam desain, part di setiap set, dan apakah kontak menyala. Gunakan untuk memeriksa bagaimana kontak diatur sebelum Anda menggerakkan rakitan.", + "help.document.inspect": "Menampilkan ringkasan desain: komponen, bodi, sketsa, fitur, dan parameternya. Gunakan untuk memahami isi desain yang tidak Anda buat sendiri.", + "help.document.units": "Mengubah satuan yang dipakai desain untuk menampilkan panjang, seperti mm atau inci. Geometri tetap berukuran sama; hanya tampilan dan angka polos yang mengikuti.", + "help.edit.checkpoint": "Mengingat desain, sketsa aktif, dan riwayat urungkan sebagaimana keadaannya sekarang. Gunakan sebelum mencoba sesuatu yang berisiko dan mungkin ingin Anda urungkan.", + "help.edit.restore_checkpoint": "Kembali ke sebuah checkpoint, termasuk riwayat urungkan. Gunakan untuk membuang semua yang dilakukan sejak checkpoint dalam satu langkah.", + "help.explode.create": "Membuat tampilan urai yang mendorong part menjauh dari pusat rakitan, atau sesuai perpindahan yang Anda atur. Gunakan untuk menunjukkan cara rakitan dipasang, untuk petunjuk atau presentasi.", "help.face.delete": "Menghapus muka terpilih dan membiarkan muka di sekitarnya menutup celah, atau membiarkan celah terbuka sebagai permukaan. Gunakan untuk menghilangkan tepi membulat, lubang, atau boss yang menghalangi.", + "help.file.insert_mesh": "Menambahkan file 3MF, STL, OBJ, PLY, atau glTF ke desain sebagai bodi mesh dalam komponen baru. Gunakan untuk menaruh model hasil unduhan atau hasil pindai di samping desain Anda sebagai referensi.", + "help.file.insert_step": "Menambahkan file STEP, IGES, FreeCAD, atau Rhino ke desain sebagai bodi solid dalam komponen baru; part sebuah rakitan menjadi komponen bersarang. Gunakan untuk membawa masuk model dari pemasok atau program CAD lain.", + "help.file.save_mesh": "Menyimpan bodi sebagai mesh STL, 3MF, atau OBJ, dengan kehalusan muka lengkung sesuai pilihan Anda. Gunakan untuk menyiapkan part untuk pencetakan 3D atau untuk program yang membaca mesh.", + "help.file.send_to_slicer": "Menyimpan bodi terpilih sebagai file 3MF dan membukanya di slicer yang Anda pilih. Gunakan untuk beralih dari model ke pencetakan dalam satu langkah.", "help.font_details": "Antarmuka memakai font bawaan egui (Ubuntu Light dan Hack, di bawah Ubuntu Font Licence dan lisensi MIT/Bitstream Vera; Noto Emoji dan font ikon emoji, di bawah OFL dan MIT). Teks sketsa memakai Liberation Sans (SIL OFL 1.1). Paket font opsional Crafting Apps, craft-fonts (BIZ UDPGothic, Shippori Mincho, BIZ UDMincho, Noto Sans CJK SC, Noto Sans Arabic; SIL OFL 1.1), tidak disertakan dalam build SolidCraft.", "help.fonts": "Font", + "help.frame.member": "Menempatkan profil, seperti tabung atau ekstrusi aluminium, sepanjang garis sketsa rangka, satu batang per garis. Gunakan untuk membangun rangka, rak, dan struktur las.", + "help.inspect.accessibility": "Mewarnai muka yang dapat dijangkau alat dari satu arah, dan yang tidak. Gunakan untuk memeriksa part untuk pemesinan atau pencetakan dari sisi itu.", + "help.inspect.center_of_mass": "Menampilkan pusat massa bodi, masing-masing sendiri dan semuanya bersama, dengan massa jenis seragam. Gunakan untuk memeriksa keseimbangan dan di mana part akan terguling.", + "help.inspect.colored_components": "Memberi setiap komponen warna sementaranya sendiri, tanpa mengubah tampilan yang ditetapkan. Gunakan untuk membedakan part rakitan sekilas pandang.", + "help.inspect.compare": "Membandingkan dua bodi dan menampilkan di mana yang satu memiliki material yang tidak dimiliki yang lain, beserta volume dan deviasinya. Gunakan untuk memeriksa part yang diubah atau diimpor terhadap aslinya.", + "help.inspect.continuity": "Memeriksa seberapa mulus muka bertemu di sepanjang tepinya: celah, patahan, atau lompatan kelengkungan, dan mewarnai jahitannya. Gunakan untuk menemukan tempat permukaan tidak menyatu dengan bersih.", + "help.inspect.curvature_comb": "Menggambar sisir pada kurva, tepi, atau muka yang giginya menunjukkan seberapa kuat kelengkungannya. Gunakan untuk menemukan bagian datar, patahan, dan kelengkungan yang tidak rata.", + "help.inspect.curvature_map": "Mewarnai muka menurut seberapa kuat kelengkungannya, merah di tempat radiusnya kecil. Gunakan untuk menemukan bagian yang lengkungannya tajam dan menilai kehalusan permukaan.", + "help.inspect.draft": "Mewarnai muka menurut sudut draft-nya terhadap arah tarik, dan menandai yang sudutnya terlalu kecil. Gunakan untuk memeriksa part sebelum pencetakan atau pengecoran.", + "help.inspect.environment_map": "Menampilkan model dengan hasil akhir reflektif seperti cermin. Gunakan untuk menilai alur permukaan dari cara pantulan mengalir di atasnya.", + "help.inspect.interference": "Menemukan bodi yang tumpang tindih dan menampilkan setiap tumpang tindih beserta volumenya. Gunakan untuk memeriksa rakitan terhadap part yang bertabrakan.", + "help.inspect.isocurve": "Menggambar kurva isoparametrik pada muka, yaitu grid tempat permukaan dibangun. Gunakan untuk melihat bagaimana permukaan mengalir dan di mana ia menumpuk.", + "help.inspect.measure": "Mengukur panjang, jarak, sudut, luas, dan radius antara apa yang Anda pilih, atau volume dan massa bodi. Gunakan untuk memeriksa ukuran dan kelonggaran.", + "help.inspect.minimum_radius": "Menemukan radius kelengkungan terkecil pada kurva, tepi, atau muka, beserta letaknya. Gunakan untuk memeriksa apakah alat atau nosel dapat mengikuti bentuknya.", + "help.inspect.section": "Memotong tampilan terbuka sepanjang sebuah bidang dan mengarsir muka potongnya, tanpa mengubah model. Gunakan untuk melihat bagian dalam part dan rakitan.", + "help.inspect.zebra": "Menampilkan lajur hitam putih yang terpantul pada muka model. Gunakan untuk menilai kehalusan: lajur yang terputus atau patah di sebuah tepi menandakan transisi yang buruk.", + "help.joint.as_built": "Menyambung dua part di tempat keduanya berada, tanpa memindahkan salah satunya, dan mengatur bagaimana keduanya masih boleh bergerak. Gunakan untuk part yang sudah Anda tempatkan di posisi akhirnya.", + "help.joint.cam": "Menghubungkan cam yang berputar ke pengikut, sehingga pengikut bergeser atau berayun saat kontur cam lewat di bawahnya. Gunakan untuk cam, eksentrik, dan penggerak katup.", + "help.joint.create": "Menyambung dua part pada origin sambungan yang Anda pilih dan memindahkan part kedua ke tempatnya; jenis sambungan menentukan bagaimana keduanya masih boleh bergerak. Gunakan untuk membangun rakitan yang part-nya bergerak seperti mekanisme sebenarnya.", + "help.joint.drive": "Mengatur sudut atau jarak sebuah sambungan dan menggerakkan part bersamanya, dalam batasnya; sambungan terkait dan loop tertutup ikut bergerak. Gunakan untuk memeriksa bahwa mekanisme bergerak sesuai maksud.", + "help.joint.gear": "Menghubungkan dua sambungan berputar dengan rasio roda gigi, sehingga menggerakkan satu akan memutar yang lain. Gunakan untuk roda gigi lurus dan roda gigi dalam, dengan rasio dari radius pitch atau jumlah gigi.", + "help.joint.limits": "Mengatur sejauh mana sambungan boleh berputar atau bergeser, sebagai minimum dan maksimum, atau menghapus batas itu. Gunakan agar engsel, sambungan geser, atau tuas tetap berada dalam rentang geraknya yang sebenarnya.", + "help.joint.motion_link": "Menggandengkan dua sambungan dengan rasio dan offset, sehingga menggerakkan satu akan menggerakkan yang lain. Gunakan untuk hubungan tetap antar sambungan yang tidak tercakup oleh Roda Gigi, Rack dan Pinion, atau Sekrup.", + "help.joint.origin": "Menempatkan origin sambungan bernama pada sebuah part: di pusat sebuah muka atau ujung bulat, tengah sebuah tepi, sebuah sudut, atau sebuah titik. Gunakan untuk menyiapkan tempat sambungan menempel; origin yang dipilih pada sebuah muka mengikuti muka itu saat ukuran part berubah.", + "help.joint.rack_pinion": "Menghubungkan pinion yang berputar ke rack yang bergeser, sehingga rack bergerak sejauh panjang lingkaran pitch yang digelindingi pinion. Gunakan untuk penggerak rack, kemudi, dan tahap linear.", + "help.joint.reach": "Menggerakkan sebuah titik pada part ke arah sasaran dengan mengubah sambungan pada rantainya, dalam batasnya. Gunakan untuk memposisikan mekanisme berdasarkan di mana ujungnya harus berada, seperti ujung lengan robot.", + "help.joint.rigid_group": "Mengunci part terpilih bersama sebagaimana adanya, sehingga bergerak sebagai satu kesatuan. Gunakan untuk part yang menyatu dan tidak memiliki gerakan di antaranya.", + "help.joint.screw": "Menghubungkan gerak putar dan gerak geser dengan sebuah pitch, sehingga setiap putaran maju sejauh jarak itu. Gunakan untuk lead screw, baut, dan poros berulir.", + "help.library.apply": "Menerapkan material atau tampilan dari file pustaka lokal ke bodi, muka, atau komponen. Gunakan untuk memakai ulang material yang Anda simpan dari desain lain.", + "help.library.export": "Menyimpan material dan tampilan desain ke file pustaka. Gunakan untuk membagikannya atau memakainya ulang di desain lain.", + "help.library.inspect": "Memeriksa file pustaka dan mencantumkan material serta tampilannya, tanpa mengubah desain. Gunakan untuk melihat isi sebuah pustaka sebelum menerapkannya.", "help.licences": "Lisensi", "help.material.assign": "Menetapkan bahan pembuat bodi terpilih, seperti baja, aluminium, atau ABS. Gunakan agar massa dan bobot sesuai dengan part sebenarnya.", + "help.material.properties.validate": "Memeriksa kartu data material untuk angka yang konsisten dan nilai yang hilang. Gunakan sebelum mengandalkan data itu untuk massa atau kekakuan.", + "help.mesh.convert": "Membangun ulang bodi mesh, seperti STL, menjadi solid yang dapat diedit dengan muka datar dan silindris. Gunakan untuk memodifikasi part mekanis impor seperti bodi yang dimodelkan.", "help.mit": "Lisensi MIT", + "help.motion.study": "Mengatur nilai sambungan pada langkah-langkah di sepanjang linimasa dan memutarnya sebagai animasi. Gunakan untuk melihat mekanisme bergerak melalui suatu urutan dan memeriksa kelonggaran di sepanjang jalannya.", + "help.motion.trace": "Menelusuri jalur yang diikuti sebuah titik pada part selama studi gerak diputar atau sambungan digerakkan, dan menyimpannya sebagai kurva sketsa 3D saat Anda mengonfirmasi. Gunakan untuk melihat jangkauan mekanisme, atau membangun di atas jalurnya.", "help.notice": "NOTICE", + "help.occurrence.ground": "Memasang part tetap di tempatnya, atau membebaskannya lagi, sehingga sambungan menggerakkan part lain di sekitarnya. Gunakan untuk rangka atau dasar tempat seluruh rakitan lainnya dibangun.", "help.occurrence.move": "Memindahkan part rakitan hanya sepanjang kebebasan yang diizinkan oleh sambungannya. Pilih sebuah muka part, lalu seret panah atau busur yang tersedia.", "help.parameters.change": "Membuka tabel nilai dan ekspresi bernama yang menggerakkan dimensi desain. Gunakan untuk mengubah ukuran di satu tempat dan membuat semua fitur yang memakainya mengikuti.", + "help.parameters.export": "Menyimpan parameter desain sebagai CSV atau JSON. Gunakan untuk mengeditnya di spreadsheet atau memindahkannya ke desain lain.", + "help.parameters.graph": "Menampilkan parameter dan fitur mana yang memakai yang lain, beserta loop atau error di antaranya. Gunakan untuk melihat apa yang akan terpengaruh oleh perubahan sebuah parameter.", + "help.parameters.import": "Membaca parameter dari CSV atau JSON ke dalam desain, semuanya atau tidak sama sekali. Gunakan untuk membawa masuk nilai dari spreadsheet atau desain lain.", + "help.parameters.move": "Memindahkan parameter pengguna ke sebelum atau sesudah parameter lain dalam daftar. Gunakan untuk menjaga parameter yang berkaitan tetap berdekatan.", + "help.parts.fastener": "Menambahkan sekrup, baut, mur, washer, pin, atau bearing standar dengan ukuran yang Anda pilih. Bila ditempatkan pada sebuah lubang, ia duduk di lubang itu dengan sebuah sambungan, jadi Anda tidak perlu memodelkan pengencang sendiri.", + "help.parts.insert": "Menyisipkan part standar dengan keluarga, ukuran, dan panjang yang Anda pilih. Bila ditempatkan pada tepi atau dinding sebuah lubang, ia duduk pada lubang itu dengan sambungan kaku.", + "help.parts.library": "Membuka part standar: sekrup, baut, mur, washer, sekrup set, pin dowel, dan bearing dengan ukuran standar. Gunakan untuk menambahkan part standar alih-alih memodelkannya sendiri.", + "help.parts.resize": "Mengubah ukuran atau panjang part standar yang disisipkan dan membangunnya ulang; sambungannya menjaganya tetap terpasang. Gunakan bila sekrup ternyata terlalu panjang atau lubang memerlukan ukuran lain.", + "help.plastic.assign_rule": "Menetapkan aturan plastik ke bodi, sehingga fitur plastiknya memakai draft dan kelonggarannya sebagai nilai bawaan. Gunakan untuk menyiapkan part cetakan sekali untuk semua fiturnya.", + "help.plastic.boss": "Menambahkan boss sekrup pada sebuah muka: tiang dengan lubang opsional, draft, fillet pangkal, dan rusuk penopang. Gunakan untuk titik sekrup pada casing hasil cetakan atau cetak 3D.", + "help.plastic.lip": "Menambahkan lip atau alur pasangannya sepanjang tepi bodi yang di-shell. Gunakan agar dua belahan casing saling terkunci posisinya dan menutup rapat.", + "help.plastic.manage_rules": "Membuat dan mengedit aturan plastik: material, tebal dinding, radius, kelonggaran, dan draft. Gunakan untuk menjaga fitur-fitur part cetakan tetap konsisten.", + "help.plastic.rest": "Menambahkan rest pada sebuah muka: bantalan bulat atau persegi panjang, padat atau berongga, dengan draft. Gunakan sebagai penopang atau standoff pada casing cetakan.", + "help.plastic.snap_fit": "Menambahkan kait snap-fit pada sebuah muka, dengan panjang, ketebalan, dan pengaitnya. Gunakan untuk mengait part satu sama lain tanpa sekrup.", "help.pointcloud.insert": "Sisipkan hasil pindai PLY, XYZ, atau PTS sebagai awan titik di samping model. Gunakan sebagai referensi saat membangun model dari data hasil pengukuran.", + "help.sheet.convert": "Mengubah pelat atau bodi yang di-shell dengan ketebalan merata menjadi bodi pelat logam, dengan tekukan pada tepi yang Anda pilih. Gunakan untuk membuat part yang sudah ada dapat dibuka lipatannya.", + "help.sheet.export_bend_table": "Menyimpan tabel tekukan bodi pelat logam sebagai CSV atau JSON: sudut, radius, dan posisi tekukan pada bentuk datar. Gunakan untuk mesin tekuk (press brake) atau bengkel.", + "help.sheet.export_dxf": "Menyimpan pola datar sebagai DXF, dengan kontur, garis tekukan, dan catatan tekukan opsional. Gunakan untuk pemotongan laser, plasma, atau waterjet.", + "help.sheet.flange": "Menambahkan flens: pelat dasar dari sketsa, dinding sepanjang kontur sketsa terbuka, atau dinding tertekuk pada tepi pelat. Gunakan untuk membangun part pelat logam.", + "help.sheet.flat_pattern": "Membuat pola datar bodi pelat logam, lengkap dengan ukuran, potongan, dan garis tekukannya. Gunakan untuk memeriksa blanko sebelum Anda memotongnya.", + "help.sheet.fold": "Melipat pelat sepanjang garis sketsa dengan sudut dan radius tertentu. Gunakan untuk melipat pelat datar setelah fitur potongan dibuat padanya.", + "help.sheet.hem": "Melipat tepi pelat kembali menutupi dirinya sendiri. Gunakan untuk tepi yang aman dan kaku tanpa duri tajam.", + "help.sheet.manage_rules": "Membuat dan mengedit aturan pelat logam: ketebalan, radius tekukan, faktor K, relief, dan celah. Gunakan agar sesuai dengan material dan perkakas bengkel Anda.", + "help.sheet.path_flange": "Menambahkan rangkaian flens sepanjang tepi pelat, mengikuti rantai terbuka garis sketsa. Gunakan untuk profil seperti bentuk U atau Z dalam satu langkah.", + "help.sheet.perforate": "Menambahkan hamparan lubang di seluruh pelat datar, dalam pola selang-seling atau persegi. Gunakan untuk ventilasi, kisi, dan panel yang lebih ringan.", + "help.sheet.refold": "Melipat kembali bodi pelat logam yang sudah dibuka lipatannya. Gunakan setelah menambahkan fitur pada pelat datar.", + "help.sheet.unfold": "Membuka lipatan semua tekukan bodi pelat logam. Gunakan untuk menambahkan lubang atau potongan yang harus melintasi tekukan, lalu lipat ulang.", + "help.sketch.arc.center_point": "Menggambar busur lingkaran mengelilingi titik pusat, dari titik awal ke titik akhir. Gunakan bila pusat busur diketahui, seperti radius di sekeliling lubang atau pivot.", + "help.sketch.arc.tangent": "Menggambar busur yang dimulai di ujung garis atau busur dan berlanjut secara tangen terhadapnya. Gunakan untuk melanjutkan rantai dengan lengkungan halus, bukan sudut.", + "help.sketch.arc.three_point": "Menggambar busur dari titik awal ke titik akhir melalui titik ketiga. Gunakan bila busur harus melewati titik-titik tertentu, bukan mengelilingi pusat yang diketahui.", + "help.sketch.arc3d": "Menggambar busur lingkaran eksak di ruang melalui tiga titik, dengan Sketsa 3D aktif. Gunakan untuk lengkungan pada jalur dan rel pemandu yang keluar dari bidang sketsa.", + "help.sketch.auto_constrain": "Menambahkan batasan yang hampir sudah dimiliki sketsa, seperti horizontal, paralel, atau berimpit, lalu memberinya dimensi hingga terbatasi penuh. Gunakan untuk mengunci sketsa yang digambar kasar dalam satu langkah.", + "help.sketch.auto_constrain_from_datum": "Bekerja seperti AutoConstrain, tetapi mengukur posisi dari titik datum yang Anda pilih, bukan dari origin. Gunakan bila part ditata dari satu titik referensi, seperti sudut atau lubang.", + "help.sketch.blend_curve": "Menyambung ujung dua kurva dengan spline halus yang tangen terhadap keduanya, atau juga kontinu kelengkungannya bila Anda memilihnya. Gunakan untuk menjembatani celah antar kurva tanpa patahan.", + "help.sketch.break": "Memecah kurva menjadi potongan di tempat kurva lain memotongnya, atau pada titik yang Anda tempatkan di atasnya. Gunakan bila sebagian kurva memerlukan batasannya sendiri atau harus dihapus tersendiri.", + "help.sketch.cancel": "Meninggalkan sketsa dan membuang semua yang diubah sejak sketsa dibuat atau dibuka untuk diedit. Urungkan mengembalikan perubahan itu.", + "help.sketch.centerline": "Mengalihkan garis antara garis normal dan garis tengah, yaitu sumbu yang tidak disertakan profil. Gunakan garis tengah sebagai sumbu untuk revolve, cermin, atau batasan simetri.", + "help.sketch.chamfer.distance_angle": "Memberi bevel pada sudut di antara dua garis, ditentukan oleh jarak sepanjang garis pertama dan sebuah sudut. Gunakan bila gambar kerja menyatakan chamfer sebagai panjang dan sudut, seperti 2 mm × 30°.", + "help.sketch.chamfer.equal_distance": "Memberi bevel pada sudut di antara dua garis dengan jarak yang sama ke belakang sepanjang keduanya. Gunakan untuk chamfer 45° yang lazim pada sebuah kontur.", + "help.sketch.chamfer.two_distance": "Memberi bevel pada sudut di antara dua garis, ditentukan oleh jarak terpisah sepanjang setiap garis. Gunakan untuk chamfer yang bukan 45°, yang dinyatakan sebagai dua panjang.", + "help.sketch.circle.center": "Menggambar lingkaran dari pusat dan diameternya. Gunakan untuk lubang, boss, dan fitur bulat lain yang pusatnya diketahui.", + "help.sketch.circle.three_point": "Menggambar lingkaran melalui tiga titik. Gunakan bila lingkaran harus melewati titik-titik tertentu, misalnya dari geometri yang sudah ada.", + "help.sketch.circle.three_tangent": "Menggambar lingkaran yang menyentuh tiga garis, lingkaran, atau busur. Gunakan untuk memasukkan lingkaran ke ruang di antara tiga kurva, seperti lingkaran dalam sebuah segitiga.", + "help.sketch.circle.two_point": "Menggambar lingkaran dari kedua ujung diameternya. Gunakan bila titik tepi lingkaran diketahui, bukan pusatnya.", + "help.sketch.circle.two_tangent": "Menggambar lingkaran dengan ukuran tertentu yang menyentuh dua garis, lingkaran, atau busur. Gunakan untuk pembulatan dengan radius tetap di antara dua kurva.", + "help.sketch.conic": "Menggambar kurva konik di antara dua titik ujung, dibentuk oleh titik puncak dan nilai rho. Gunakan untuk transisi halus yang kepenuhannya ingin Anda atur, dari datar hingga hampir menjadi sudut tajam.", + "help.sketch.constrainer": "Menerapkan batasan yang hampir sudah dimiliki satu atau dua kurva terpilih, seperti horizontal, paralel, tangen, atau sama. Gunakan untuk merapikan sketsa gambar tangan, satu relasi demi satu.", + "help.sketch.constraint.coincident": "Membuat sebuah titik berada pada titik lain, kurva, atau sumbu sketsa. Gunakan untuk menutup celah pada profil atau menaruh titik pada garis.", + "help.sketch.constraint.collinear": "Membuat dua garis berada pada satu garis lurus. Gunakan untuk menyejajarkan tepi yang terputus, seperti kedua sisi sebuah takik.", + "help.sketch.constraint.concentric": "Memberi dua lingkaran atau busur pusat yang sama. Gunakan untuk cincin, counterbore, dan poros dengan bahu.", + "help.sketch.constraint.curvature": "Membuat dua kurva yang bertemu di titik ujung berlanjut dengan kelengkungan yang sama (G2). Gunakan bila sambungan tangen belum cukup halus, misalnya pada permukaan yang terlihat.", + "help.sketch.constraint.equal": "Membuat garis sama panjang, atau lingkaran dan busur sama radius. Gunakan agar satu dimensi mengendalikan beberapa fitur yang sama.", + "help.sketch.constraint.fix": "Mengunci titik atau kurva di tempatnya, atau membebaskannya lagi. Gunakan untuk geometri yang tidak boleh bergerak saat Anda menyeret atau me-solve sketsa.", + "help.sketch.constraint.horizontal_vertical": "Membuat sebuah garis, atau garis di antara dua titik, menjadi horizontal atau vertikal, mana yang lebih dekat. Gunakan untuk meluruskan kontur terhadap sumbu sketsa.", + "help.sketch.constraint.midpoint": "Menempatkan titik di tengah garis, atau membuat titik tengah dua garis bertemu. Gunakan untuk memusatkan geometri pada sebuah tepi.", + "help.sketch.constraint.parallel": "Membuat dua garis paralel. Gunakan untuk sisi yang harus tetap paralel saat dimensi lain berubah.", + "help.sketch.constraint.perpendicular": "Membuat dua garis bertemu pada sudut siku-siku. Gunakan untuk sudut tegak lurus antara garis yang tidak berada pada sumbu sketsa.", + "help.sketch.constraint.polygon": "Menjadikan rantai garis tertutup sebagai poligon beraturan: sisi sama panjang, dengan setiap sudut berada pada satu lingkaran. Gunakan untuk membuat poligon gambar tangan menjadi beraturan dan menjaganya tetap begitu.", + "help.sketch.constraint.symmetry": "Membuat dua titik atau kurva saling mencerminkan di seberang sebuah garis atau terhadap sebuah titik. Gunakan untuk menjaga profil tetap simetris saat Anda mengubah satu sisi.", + "help.sketch.constraint.tangent": "Membuat dua kurva bersentuhan secara tangen, seperti garis dan busur. Gunakan untuk sambungan halus tanpa patahan.", + "help.sketch.construction": "Mengalihkan kurva antara geometri normal dan geometri konstruksi, yang tidak disertakan profil. Gunakan kurva konstruksi sebagai bantuan untuk tata letak, referensi, dan dimensi.", "help.sketch.construction_line": "Buat garis bantu tak hingga melalui dua titik. Gunakan untuk menata sketsa tanpa memecah profil.", "help.sketch.construction_line.angle": "Buat garis bantu tak hingga dengan sudut terhadap sumbu X sketsa. Pilih titiknya dan ketik sudutnya, atau pilih arahnya.", "help.sketch.construction_line.bisector": "Pilih dua garis sketsa lurus yang berpotongan, lalu pilih salah satu garis bagi sudut. Hasilnya mengikuti garis sumbernya.", @@ -1412,11 +1564,71 @@ "help.sketch.construction_line.tangent_external": "Pilih lingkaran atau busur lingkaran, lalu sebuah titik luar dan satu solusi garis singgung. Pratinjau hanya menampilkan titik singgung pada busur yang digambar.", "help.sketch.construction_line.tangent_two": "Pilih dua lingkaran atau busur lingkaran, lalu pilih garis singgung persekutuan di pratinjau. Titik singgung harus berada pada kurva yang digambar.", "help.sketch.construction_line.vertical": "Buat garis bantu vertikal tak hingga melalui sebuah titik. Batasan vertikalnya tetap berlaku pada pengeditan berikutnya.", + "help.sketch.copy": "Menyalin kurva dan titik sketsa terpilih, beserta batasan dan dimensi di antaranya. Tempelkan ke sketsa ini atau sketsa lain.", + "help.sketch.create": "Memulai sketsa 2D baru pada sebuah bidang atau muka datar model. Setiap ekstrusi, revolve, dan sweep dimulai dengan sebuah sketsa.", + "help.sketch.cut": "Menyalin geometri sketsa terpilih dan menghapusnya dari sketsa, dalam satu langkah urungkan. Tempelkan di tempat lain di sketsa ini atau ke sketsa lain.", + "help.sketch.dimension": "Menambahkan dimensi driving: panjang, jarak, sudut, radius, atau diameter, tergantung apa yang Anda pilih. Ketik nilai atau nama parameter, dan sketsa mengikutinya.", + "help.sketch.edit": "Membuka sketsa yang sudah ada untuk diedit. Fitur yang dibangun di atasnya diperbarui saat Anda menyelesaikan sketsa.", + "help.sketch.ellipse": "Menggambar elips dari pusatnya, ujung sumbu utamanya, dan sebuah titik pada sisi minornya. Gunakan untuk bukaan oval, cam, dan bentuk bulat memanjang lainnya.", "help.sketch.equation_curve": "Menggambar kurva dari rumus: x(t) dan y(t), atau y = f(x), pada suatu rentang. Rumusnya dapat memakai parameter dan kurva mengikutinya: gunakan untuk gelombang sinus, spiral, atau involut gigi roda gigi.", + "help.sketch.extend": "Memperpanjang garis atau busur hingga kurva terdekat yang menghalanginya, atau memanjangkan dua kurva hingga bertemu. Gunakan untuk menutup celah pada profil setelah trim atau pengeditan.", + "help.sketch.fillet": "Membulatkan sudut tempat dua garis atau busur bertemu dengan busur berradius tertentu. Beberapa sudut yang dibulatkan bersama memakai satu radius yang sama.", + "help.sketch.finish": "Meninggalkan sketsa dan kembali ke model. Profil tertutupnya lalu siap untuk Ekstrusi, Revolve, dan fitur lain.", + "help.sketch.finish_auto_constrain": "Menjalankan AutoConstrain pada sketsa, lalu menyelesaikannya. Gunakan untuk meninggalkan sketsa yang terbatasi penuh dalam satu langkah.", + "help.sketch.fit_curves_to_section": "Memotong bodi mesh atau solid dengan bidang sketsa dan mencocokkan garis dan busur dengan penampangnya. Gunakan untuk membangun ulang part hasil pindai atau impor sebagai sketsa yang dapat diedit.", + "help.sketch.include_3d": "Membawa tepi model, titik sudut, dan kurva sketsa lain ke sketsa ini sebagai referensi tertaut, dengan meratakan yang berada di luar bidangnya ke atasnya. Gunakan untuk membangun di atas geometri yang tidak terletak pada bidang sketsa.", + "help.sketch.infinite": "Mengalihkan garis antara garis normal dan garis konstruksi tak hingga yang membentang di seluruh tampilan. Gunakan sebagai referensi tata letak yang tidak pernah berakhir terlalu pendek.", + "help.sketch.insert_dxf": "Membaca kurva gambar DXF ke dalam sketsa, diskalakan, diputar, dan ditempatkan sesuai pengaturan Anda. Gunakan untuk membangun di atas gambar 2D dari program lain, seperti kontur potong laser.", + "help.sketch.insert_svg": "Membaca path gambar SVG ke dalam sketsa, diskalakan, diputar, dan ditempatkan sesuai pengaturan Anda. Gunakan untuk mengubah logo atau kontur vektor menjadi kurva yang dapat diekstrusi.", + "help.sketch.inspect": "Membuka laporan tentang sketsa yang terbuka: berapa derajat kebebasan yang tersisa dan apakah sketsa terbatasi penuh. Gunakan untuk mengetahui apa yang masih bergerak sebelum Anda menyelesaikan sketsa.", + "help.sketch.intersect": "Menambahkan kurva atau titik tempat bodi, muka, tepi, atau sketsa lain memotong bidang sketsa. Semuanya tetap tertaut dan mengikuti model saat model berubah.", + "help.sketch.intersection_curve": "Membuat kurva 3D di tempat dua bodi atau muka bertemu, tertaut padanya. Gunakan sebagai jalur atau rel pemandu yang mengikuti model.", + "help.sketch.isoparametric_curve": "Membuat kurva 3D sepanjang arah U atau V sebuah muka melalui titik yang Anda pilih, tertaut ke muka itu. Gunakan sebagai jalur atau garis pisah yang mengikuti permukaan.", + "help.sketch.line": "Menggambar garis-garis tersambung titik demi titik, sebagai rantai terbuka atau kontur tertutup. Ini alat dasar untuk sebagian besar profil.", + "help.sketch.line.midpoint": "Menggambar garis dari titik tengahnya ke satu ujung, dengan ujung lainnya dicerminkan. Gunakan untuk garis yang dipusatkan pada sebuah titik, seperti tepi simetris.", + "help.sketch.line3d": "Menggambar garis lurus tersambung di antara titik-titik di ruang, dengan Sketsa 3D aktif. Gunakan untuk jalur dan rangka yang keluar dari bidang sketsa.", + "help.sketch.mirror": "Mencerminkan kurva dan titik sketsa terhadap sebuah garis atau sumbu dan menambahkan batasan simetri. Gunakan untuk menggambar setengah profil simetris lalu melengkapinya.", + "help.sketch.move": "Memindahkan atau memutar kurva dan titik sketsa, atau salinannya. Gunakan untuk memposisikan ulang geometri sambil mempertahankan bentuk dan batasannya.", + "help.sketch.offset": "Membuat salinan rantai kurva pada jarak tertentu, di satu sisi atau kedua sisi. Gunakan untuk dinding, kontur, dan kelonggaran; jaraknya tetap menjadi dimensi yang dapat diedit.", + "help.sketch.paste": "Menempelkan geometri sketsa yang Anda salin atau potong ke sketsa aktif. Tempatkan di sebuah titik atau geser dengan offset.", + "help.sketch.pattern.circular": "Menyalin geometri sketsa mengelilingi titik pusat, sepanjang satu lingkaran penuh atau sudut tertentu. Gunakan untuk lingkaran baut, jari-jari, dan gigi.", + "help.sketch.pattern.rectangular": "Menyalin geometri sketsa dalam baris dan kolom dengan jarak tertentu. Gunakan untuk grid lubang, slot, atau ventilasi.", + "help.sketch.point": "Menempatkan titik sketsa. Gunakan sebagai referensi untuk dimensi, batasan, pusat lubang, atau fitur berikutnya.", + "help.sketch.point3d": "Menempatkan titik di ruang, dengan Sketsa 3D aktif. Gunakan sebagai referensi untuk kurva dan jalur 3D.", + "help.sketch.polygon.circumscribed": "Menggambar poligon beraturan di sekeliling lingkaran, berukuran dari pusatnya hingga tengah sebuah sisi. Gunakan bila lebar antar sisi datar diketahui, seperti pada mur segi enam.", + "help.sketch.polygon.edge": "Menggambar poligon beraturan dari kedua ujung satu sisinya. Gunakan bila panjang dan posisi satu sisi diketahui.", + "help.sketch.polygon.inscribed": "Menggambar poligon beraturan di dalam lingkaran, berukuran dari pusatnya hingga sebuah sudut. Gunakan bila ukuran antar sudut diketahui.", + "help.sketch.project": "Membawa tepi model, muka, titik sudut, dan kurva sketsa lain ke sketsa ini, diproyeksikan ke bidangnya. Semuanya tetap tertaut dan mengikuti model saat model berubah.", + "help.sketch.project_to_surface": "Memproyeksikan kurva sketsa lain sepanjang normal sketsa ini ke sebuah muka, sebagai kurva 3D tertaut. Gunakan untuk menaruh logo, garis pisah, atau jalur pada permukaan lengkung.", + "help.sketch.rectangle.center": "Menggambar persegi panjang dari pusat dan satu sudutnya. Gunakan untuk kontur yang dipusatkan pada sebuah titik, seperti origin.", + "help.sketch.rectangle.rounded": "Menggambar persegi panjang dengan keempat sudut dibulatkan pada satu radius. Gunakan untuk pelat, tutup, dan bukaan dengan sudut membulat.", + "help.sketch.rectangle.three_point": "Menggambar persegi panjang dari satu sisi dan sebuah titik di sisi seberangnya. Gunakan untuk persegi panjang yang miring terhadap sumbu sketsa.", + "help.sketch.rectangle.two_point": "Menggambar persegi panjang dari dua sudut yang berseberangan. Gunakan untuk kontur pelat, blok, dan kantong.", + "help.sketch.scale": "Menskalakan kurva dan titik sketsa dengan suatu faktor terhadap titik dasar. Gunakan untuk mengubah ukuran sebagian sketsa tanpa menggambarnya lagi.", + "help.sketch.slot.arc_center": "Menggambar slot melengkung dengan lebar tertentu sepanjang busur yang ditentukan oleh pusat, titik awal, dan titik akhirnya. Gunakan untuk slot penyetelan di sekeliling pivot.", + "help.sketch.slot.arc_three_point": "Menggambar slot melengkung dengan lebar tertentu sepanjang busur melalui tiga titik. Gunakan bila lintasan slot harus melewati titik-titik tertentu.", + "help.sketch.slot.center_point": "Menggambar slot lurus dengan lebar tertentu dari pusatnya ke pusat salah satu busur ujung. Gunakan untuk slot yang dipusatkan pada sebuah titik.", + "help.sketch.slot.center_to_center": "Menggambar slot lurus dengan lebar tertentu di antara pusat kedua busur ujungnya. Gunakan untuk slot yang didimensikan dari pusat ke pusat, seperti slot penyetelan untuk sekrup.", + "help.sketch.slot.overall": "Menggambar slot lurus dengan lebar tertentu dari satu ujung luar ke ujung lainnya. Gunakan bila panjang total slot yang terpenting.", + "help.sketch.spline.control_point": "Menggambar spline kubik yang dibentuk oleh titik kontrol yang tidak dilaluinya. Gunakan untuk kurva halus yang mengalir, yang Anda bentuk dengan menggeser titik-titiknya.", + "help.sketch.spline.control_point_5": "Menggambar spline derajat 5 yang dibentuk oleh titik kontrol. Gunakan bila kurva harus menyambung dengan kelengkungan yang lebih halus, misalnya pada permukaan bergaya yang terlihat.", + "help.sketch.spline.fit_point": "Menggambar spline halus yang melewati setiap titik yang Anda tempatkan, terbuka atau tertutup. Gunakan untuk menjiplak bentuk melalui titik-titik yang diketahui.", + "help.sketch.spline3d": "Menggambar kurva halus di ruang melalui titik-titik, dengan Sketsa 3D aktif. Gunakan untuk jalur sweep dan jalur kabel yang keluar dari bidang sketsa.", + "help.sketch.spun_profile": "Membuat di bidang sketsa kontur yang disapu sebuah bodi saat berputar mengelilingi sumbu. Gunakan untuk mendapatkan siluet part bubutan untuk revolve atau gambar.", + "help.sketch.text": "Menulis teks di dalam sketsa dengan font, tinggi, dan sudut tertentu; katanya dapat mengikuti parameter. Ekstrusi atau beri emboss untuk label dan tulisan huruf.", + "help.sketch.transform": "Menskalakan, memutar, dan memindahkan geometri sketsa terhadap titik pusat dalam satu langkah. Gunakan untuk memposisikan ulang dan mengubah ukuran sebuah pilihan sekaligus.", + "help.sketch.trim": "Menghapus potongan kurva di antara perpotongan terdekat di sekitar tempat Anda mengklik. Gunakan untuk merapikan garis yang tumpang tindih menjadi profil tertutup.", "help.solid.align": "Memindahkan bodi terpilih sehingga titik, muka, atau bidang padanya bertemu titik, muka, atau bidang di tempat lain. Gunakan untuk menempatkan part pada part lain tanpa mengukur offset.", + "help.solid.boundary_fill": "Membuat solid dari wilayah yang dikelilingi bodi alat yang tumpang tindih, dengan hanya mengisi wilayah yang Anda pilih. Gunakan untuk membangun bentuk dari ruang yang dibatasi bersama oleh beberapa bodi.", + "help.solid.bounding_solid": "Membuat kotak yang membungkus bodi terpilih, dengan margin opsional. Gunakan untuk melihat ukuran keseluruhan sebuah part, atau sebagai bahan baku tempat part itu dimesin.", + "help.solid.box": "Membuat kotak dari panjang, lebar, dan tinggi, dari sebuah titik sudut atau dipusatkan pada sebuah titik. Gunakan untuk blok cepat, pengganti sementara, atau bodi tempat fitur akan dipotongkan.", "help.solid.chamfer": "Memotong tepi terpilih dengan bevel datar sejauh jarak yang Anda atur. Gunakan untuk menumpulkan tepi tajam atau mempermudah masuknya pin atau sekrup.", + "help.solid.coil": "Membuat bodi heliks, seperti pegas, dari diameter, jumlah putaran, tinggi atau pitch, dan penampang bulat atau persegi. Gunakan untuk pegas, koil, dan rusuk heliks.", "help.solid.combine": "Menggabungkan bodi, memotongnya dari bodi target, atau hanya menyimpan volume yang dimiliki bersama, dan dapat mempertahankan bodi alat. Gunakan untuk menyatukan part, memotong satu bentuk dengan bentuk lain, atau menemukan tempat dua bodi bertumpang tindih.", + "help.solid.cone": "Membuat kerucut dari lingkaran dasar dan tinggi, atau kerucut terpancung bila bagian atasnya juga memiliki radius. Gunakan untuk taper, corong, countersink, dan ujung runcing.", + "help.solid.cylinder": "Membuat silinder dari radius atau diameter dan tinggi, berdiri pada sebuah titik sepanjang sumbu. Gunakan untuk poros, pin, boss, dan blanko bulat.", "help.solid.draft": "Memiringkan muka terpilih sebesar sudut yang Anda atur, diukur dari bidang netral. Gunakan pada part cetakan atau cor agar mudah dilepas dari cetakannya.", + "help.solid.emboss": "Menaikkan profil sketsa, seperti teks atau logo, dari sebuah muka, atau membenamkannya ke dalamnya. Gunakan untuk tulisan, label, dan tanda pada muka datar atau bulat.", "help.solid.extrude": "Mendorong profil sketsa menjadi 3D sejauh jarak tertentu, menambah atau menghilangkan material. Gunakan untuk mengubah garis bentuk datar menjadi blok, boss, atau kantong.", "help.solid.fillet": "Membulatkan tepi terpilih dengan perpaduan melingkar yang halus sebesar radius yang Anda atur. Gunakan untuk memperhalus tepi tajam demi kekuatan, kenyamanan, atau tampilan.", "help.solid.hole": "Membor lubang bulat dengan diameter yang Anda atur pada titik pilihan di sebuah muka. Gunakan untuk celah sekrup, pin, dan bukaan bulat sederhana lainnya.", @@ -1425,20 +1637,60 @@ "help.solid.move": "Memindahkan atau memutar bodi terpilih, atau salinannya, sesuai jarak dan sudut yang Anda atur. Gunakan untuk memposisikan ulang bodi atau membuat bodi kedua di tempat lain.", "help.solid.offset_face": "Mendorong muka terpilih ke dalam atau ke luar sejauh jarak yang Anda atur, dan muka di sekitarnya mengikuti. Gunakan untuk menebalkan dinding, memperdalam kantong, atau meninggikan boss tanpa mengedit sketsa.", "help.solid.pattern.circular": "Mengulang objek terpilih mengelilingi sumbu, sesuai jumlah dan sudut total yang Anda atur. Gunakan untuk lingkaran baut, jari-jari, sirip, dan gigi.", + "help.solid.pattern.path": "Menyalin fitur atau bodi sepanjang jalur sketsa, menurut jumlah dan jarak. Gunakan untuk lubang di sepanjang tepi lengkung atau part di sepanjang rel.", "help.solid.pattern.rectangular": "Mengulang objek terpilih dalam satu atau dua arah dengan jumlah dan jarak yang Anda atur. Gunakan untuk baris dan grid lubang, rusuk, pin, atau kaki.", + "help.solid.pipe": "Membuat pipa bulat sepanjang jalur kurva sketsa, padat atau berongga dengan ketebalan dinding. Gunakan untuk tabung, kawat, kabel, dan rel.", "help.solid.press_pull": "Menambah atau menghilangkan material saat Anda menyeret muka, atau membulatkan tepi saat Anda menyeretnya. Gunakan untuk perubahan cepat langsung pada model.", "help.solid.remove": "Mengeluarkan bodi terpilih dari desain mulai titik ini di timeline; langkah sebelumnya tetap memilikinya. Gunakan untuk membuang bodi bantu, seperti alat potong yang tidak diperlukan lagi.", "help.solid.replace_face": "Memindahkan muka datar terpilih ke sebuah bidang atau ke muka yang sejajar, dan bodi bertambah atau menyusut hingga bertemu. Gunakan untuk meratakan muka dengan part lain atau bidang konstruksi.", "help.solid.revolve": "Memutar profil sketsa mengelilingi sumbu untuk membuat solid bulat. Gunakan untuk poros, kenop, mangkuk, dan part apa pun yang bulat terhadap garis tengah.", + "help.solid.rib": "Menambahkan dinding tipis sepanjang kurva sketsa terbuka, diisi hingga muka-muka bodi berikutnya. Gunakan untuk memperkaku dinding dan sudut, misalnya pada part cetakan atau cetak 3D.", "help.solid.scale": "Membuat bodi terpilih lebih besar atau lebih kecil dengan suatu faktor, sama ke segala arah atau satu per sumbu. Gunakan untuk mengubah ukuran bentuk impor atau memperhitungkan penyusutan.", "help.solid.shell": "Mengosongkan solid, menyisakan dinding setebal yang Anda atur, dan dapat membuka muka yang Anda pilih. Gunakan untuk kotak, penutup, dan part berdinding tipis lainnya.", + "help.solid.sphere": "Membuat bola dari radius atau diameter di sekeliling titik pusat. Gunakan untuk bola, kenop, dan ujung membulat.", "help.solid.split_body": "Memotong sebuah bodi menjadi bodi terpisah dengan bidang, muka bodi lain, atau garis sketsa. Gunakan untuk membuat part yang dicetak, dimesin, atau diwarnai sendiri-sendiri.", "help.solid.split_face": "Membagi muka terpilih sepanjang bidang, muka bodi lain, atau kurva sketsa, tanpa memotong bodi. Gunakan untuk memberi sebagian muka drafnya, warnanya, atau offset-nya sendiri.", "help.solid.sweep": "Menyeret profil sketsa sepanjang jalur untuk membuat solid. Gunakan untuk pipa, rel, gagang, dan part berpenampang tetap yang mengikuti kurva.", + "help.solid.thread": "Menambahkan ulir metrik ISO pada muka silindris, sebagai ulir kosmetik atau dipotong ke dalam bodi. Gunakan untuk baut, lubang berulir, dan part sekrup cetak 3D.", + "help.solid.torus": "Membuat cincin dari radius mayor di sekeliling sumbunya dan radius minor untuk penampang bulatnya. Gunakan untuk O-ring, gagang, dan manik bulat.", + "help.solid.web": "Menambahkan dinding tipis sepanjang setiap kurva sketsa terbuka, masing-masing diisi hingga muka-muka bodi berikutnya. Gunakan untuk grid dinding penopang di dalam casing dan baki.", + "help.surface.blend": "Membuat permukaan yang menjembatani tepi dua bodi, tangen atau kontinu kelengkungannya terhadap keduanya. Gunakan untuk menyambung dua permukaan dengan halus melintasi celah.", + "help.surface.boundary": "Membuat permukaan yang mengisi loop dua sampai empat kurva sketsa, atau jaringan kurva. Gunakan untuk panel bentuk bebas yang ditentukan oleh tepinya.", + "help.surface.extend": "Memperpanjang permukaan melewati tepinya, sejauh jarak tertentu atau hingga sebuah bidang atau muka. Gunakan agar permukaan saling tumpang tindih sebelum Anda trim atau menjahitnya.", + "help.surface.extrude": "Mendorong kurva atau profil sketsa sejauh jarak tertentu menjadi permukaan, hanya dinding, tanpa tutup. Gunakan untuk dinding tipis dan untuk alat pemotong atau pemisah.", + "help.surface.fair": "Menghaluskan bagian dalam muka bentuk bebas sementara tepinya tetap di tempat. Gunakan untuk meratakan tonjolan dan pantulan bergelombang pada permukaan.", + "help.surface.loft": "Membuat permukaan yang melewati dua penampang atau lebih secara berurutan, tanpa tutup. Gunakan untuk shell dan transisi antar bentuk yang berbeda.", + "help.surface.match": "Membangun ulang tepi permukaan agar bertemu dengan tepi bodi lain, dengan tangen atau kelengkungan yang cocok. Gunakan untuk menutup jahitan secara halus di antara dua permukaan.", + "help.surface.offset": "Membuat salinan muka pada jarak tertentu sepanjang normalnya, sebagai bodi permukaan baru. Gunakan untuk kulit, kelonggaran, atau sisi lain sebuah dinding.", + "help.surface.patch": "Mengisi wilayah sketsa atau loop tepi tertutup dengan sebuah muka, datar atau melengkung halus. Gunakan untuk menutup lubang pada bodi permukaan.", + "help.surface.reverse_normal": "Membalik muka bodi permukaan, sehingga sisi luarnya menghadap arah lain. Gunakan sebelum menebalkan atau menjahit bila muka-mukanya mengarah ke dalam.", + "help.surface.revolve": "Memutar kurva atau profil sketsa mengelilingi sumbu menjadi permukaan, tanpa tutup. Gunakan untuk shell bulat seperti kap lampu, mangkuk, dan nosel.", + "help.surface.ruled": "Membuat pita garis lurus yang keluar dari rantai tepi, sepanjang normal muka, tangen, atau pada suatu sudut. Gunakan untuk dinding draft, run-off, dan permukaan pemisah.", + "help.surface.stitch": "Menjahit bodi permukaan bersama di sepanjang tepi yang dipakai bersama, menjadi solid bila tertutup. Gunakan untuk mengubah sekumpulan permukaan menjadi satu bodi.", + "help.surface.sweep": "Menggerakkan kurva atau profil sketsa sepanjang jalur menjadi permukaan, tanpa tutup. Gunakan untuk talang, trim, dan bentuk tipis lain yang mengikuti jalur.", + "help.surface.thicken": "Memberi ketebalan pada bodi permukaan, mengubahnya menjadi solid. Gunakan untuk membuat part yang dapat dicetak atau dimesin dari permukaan.", + "help.surface.trim": "Memotong permukaan dengan bidang, permukaan lain, atau kurva sketsa, dan mempertahankan sisi yang Anda pilih. Gunakan untuk memotong permukaan menjadi bentuk sebelum dijahit.", + "help.surface.unstitch": "Memisahkan muka sebuah bodi menjadi bodi permukaan tersendiri; solid terurai menjadi permukaan. Gunakan untuk mengerjakan ulang muka tunggal sebuah bodi.", + "help.surface.untrim": "Menghapus lubang dari sebuah muka, atau memulihkan batas luarnya ke batas alami permukaan. Gunakan untuk mendapatkan kembali permukaan penuh setelah trim.", "help.third_party": "Lisensi pihak ketiga", "help.timeline.compute_all": "Membangun ulang setiap fitur di timeline dari awal. Gunakan untuk menyegarkan model dan melihat fitur mana yang gagal.", "help.timeline.delete": "Menghapus fitur terpilih dari timeline; menghapus sketsa juga menghapus fitur yang dibangun darinya. Gunakan untuk membuang langkah yang tidak diinginkan lagi.", + "help.timeline.dependents": "Mencantumkan fitur yang akan ikut terhapus bersama sebuah fitur, dan yang akan gagal tanpanya. Gunakan sebelum menghapus atau mengubah sebuah fitur.", "help.timeline.play": "Putar ulang fitur yang tersisa satu per satu, dengan jeda setengah detik setelah setiap pembangunan ulang. Jalankan perintah ini lagi atau tekan Escape untuk berhenti; Ke akhir langsung melompat.", + "help.view.apply": "Memutar tampilan ke arah standar seperti depan, atas, atau iso, atau ke tampilan bernama yang tersimpan. Gunakan untuk melihat model dari sudut tetap.", + "help.view.delete": "Menghapus tampilan bernama yang tersimpan. Model tidak berubah.", + "help.view.fit": "Melakukan zoom dan pan agar seluruh model, atau pilihan, memenuhi tampilan. Gunakan bila model menghilang dari pandangan.", + "help.view.grid": "Menampilkan atau menyembunyikan grid di viewport. Gunakan sebagai panduan ukuran dan orientasi saat memodelkan.", + "help.view.ground_shadow": "Menampilkan atau menyembunyikan bayangan model di lantai. Gunakan untuk menilai tinggi dan posisi dengan lebih mudah.", + "help.view.home": "Memutar tampilan kembali ke arah awal dan memasukkan model ke dalam tampilan. Gunakan untuk kembali ke tampilan yang dikenal dengan cepat.", + "help.view.list": "Mencantumkan tampilan bernama yang tersimpan di desain. Gunakan untuk menemukan tampilan yang Anda simpan sebelumnya.", + "help.view.navigate": "Memutar, menggeser, atau memperbesar tampilan dengan langkah-langkah, seperti seperempat putaran ke kiri. Gunakan untuk menggerakkan kamera dengan tepat alih-alih menyeret.", + "help.view.navigation_bar": "Menampilkan atau menyembunyikan bilah navigasi di bagian bawah viewport. Sembunyikan untuk ruang lebih luas, tampilkan untuk menjangkau alat tampilan dengan mouse.", + "help.view.rename": "Memberi nama baru pada tampilan bernama yang tersimpan. Tampilannya sendiri tetap sama.", + "help.view.save": "Menyimpan posisi kamera saat ini sebagai tampilan bernama di desain. Gunakan untuk kembali ke sudut yang sama nanti, misalnya untuk tangkapan layar.", + "help.view.spin": "Memutar tampilan mengelilingi model secara terus-menerus hingga klik atau tombol berikutnya. Gunakan untuk mempresentasikan model dari segala sisi.", + "help.view.tool": "Mengunci alat kamera, yaitu orbit, pan, atau zoom, sehingga menyeret dengan tombol kiri mouse menggerakkan tampilan sampai Anda mematikannya. Gunakan pada trackpad atau tanpa tombol tengah mouse.", + "help.view.visual_style": "Mengganti cara model digambar: berbayang dengan atau tanpa tepi, wireframe, atau dengan tepi tersembunyi. Gunakan wireframe untuk melihat bagian dalam, berbayang untuk tampilan bersih.", "history.sketch_under_constrained": "Kurang dibatasi ({count} derajat kebebasan)", "history.sketch_under_constrained_one": "Kurang dibatasi ({count} derajat kebebasan)", "hole_presets.confirm_delete": "Konfirmasi hapus", @@ -1615,6 +1867,10 @@ "material.selection.mixed": "Material berbeda", "measure.at": "Pada", "measure.item": "Item {number}", + "measure.quick_copied": "Pengukuran disalin", + "measure.quick_copy": "Klik untuk menyalin pengukuran", + "measure.quick_edges": "{count} tepi", + "measure.quick_faces": "{count} muka", "messages.ago_h": "{n} j lalu", "messages.ago_min": "{n} mnt lalu", "messages.ago_s": "{n} d lalu", @@ -1767,6 +2023,11 @@ "not a move": "bukan pemindahan", "not a number": "bukan angka", "open a sketch first": "buka sketsa terlebih dahulu", + "parameters.apply_live": "Terapkan setiap perubahan saat Anda meninggalkan kolomnya", + "parameters.apply_live_tip": "Mati: perubahan menunggu sampai Terapkan atau OK, lalu desain dibangun ulang sekali untuk semuanya. Batal atau Escape membuangnya.", + "parameters.held": "{count} perubahan menunggu Terapkan", + "parameters.held_hint": "Edit ekspresi dan satuan. Perubahan menunggu Terapkan atau OK.", + "parameters.held_one": "1 perubahan menunggu Terapkan", "parts.automatic_name": "otomatis", "parts.dimension.across_flats": "Lebar antar sisi datar", "parts.dimension.bore": "Diameter lubang", @@ -1789,6 +2050,8 @@ "parts.family.set_screw": "Sekrup set soket segi enam, ujung datar", "parts.family.socket_head_cap_screw": "Sekrup kepala soket (cap screw)", "parts.family.washer": "Ring datar (washer)", + "parts.holes": "Lubang (opsional)", + "parts.placement_hint": "pilih satu lubang atau lebih untuk menempatkan part di masing-masing, atau OK menempatkannya di origin", "pattern.alignment_2": "Perataan 2", "pattern.alignment_hint": "Jumlah termasuk yang asli. Center menyeimbangkan salinan; Flip memilih sisi tambahan untuk total genap.", "pattern.direction_1": "Arah 1", @@ -1955,6 +2218,8 @@ "prefs.origin_plane_names": "Nama bidang origin", "prefs.origin_plane_names_note": "Nama yang ditampilkan di penjelajah, dialog, dan daftar. {top} adalah XY, {front} adalah XZ, dan {right} adalah YZ, dengan Z ke atas. Perintah, skrip, dan desain tersimpan tetap memakai XY, XZ, dan YZ.", "prefs.overall_speed": "Kecepatan keseluruhan", + "prefs.parameter_list_rows": "Baris daftar parameter", + "prefs.parameter_list_rows_tip": "Panah bawah di kolom nilai yang kosong menampilkan daftar parameter, favorit lebih dulu, hingga sebanyak baris ini. Lebih sedikit menjaga daftar tetap ringan di desain besar.", "prefs.persistent_ribbon_pressed": "Sorot perintah ribbon yang aktif", "prefs.persistent_ribbon_pressed_help": "Sorot perintah ribbon selama efeknya aktif. Matikan untuk mempertahankan tampilan netral dan bantuan perintah sebelumnya.", "prefs.perspective_fov": "Bidang pandang", @@ -1984,6 +2249,8 @@ "prefs.quality_fastest": "tercepat", "prefs.quality_slowest": "paling lambat", "prefs.quality_smoother": "lebih halus", + "prefs.quick_measure": "Pengukuran cepat di sudut tampilan", + "prefs.quick_measure_note": "Ukuran pilihan di sudut kanan bawah tampilan: panjang sebuah tepi, atau Ø dan R untuk tepi bulat, luas sebuah muka, posisi sebuah titik, jarak dan sudut antara dua item, total beberapa tepi atau muka. Klik menyalinnya.", "prefs.render_previous_geometry_budget": "Gunakan anggaran geometri pratinjau Render sebelumnya", "prefs.render_previous_geometry_budget_help": "Pulihkan batas geometri tetap sebelumnya (512 MiB di desktop 64-bit, 128 MiB di tempat lain) alih-alih anggaran berbasis perangkat.", "prefs.reports_saved": "{n} tersimpan", @@ -2029,6 +2296,8 @@ "prefs.sketch_tab_always_note": "Tab Sketsa tetap ada di ribbon, tepat setelah Model, selama tidak ada sketsa yang terbuka. Alat gambarnya lalu memulai sketsa pada bidang atau muka yang dipilih, atau pada bidang yang Anda pilih berikutnya, dengan alat yang sudah siap di dalamnya.", "prefs.sketch_tracking_guides": "Pemandu perataan pusat dan titik tengah", "prefs.sketch_tracking_guides_note": "Arahkan kursor ke pusat lingkaran atau titik tengah tepi lurus, lalu gerakkan secara horizontal atau vertikal untuk menampilkan pemandu titik-titik sementara.", + "prefs.sketch_tracking_points": "Pemandu penyejajaran dari titik ujung dan sudut saat menggambar", + "prefs.sketch_tracking_points_note": "Saat alat gambar menunggu sebuah titik, mengarahkan kursor ke ujung garis atau busur, sudut persegi panjang, atau titik bebas menampilkan pemandu bertitik sementara yang sama seperti untuk pusat atau titik tengah. Mati: hanya pusat dan titik tengah.", "prefs.sketching": "Menggambar sketsa", "prefs.snap_drawing_note": "Saat menggambar atau menyeret titik sketsa.", "prefs.source": "Sumber", @@ -2056,6 +2325,16 @@ "prefs.theme_follow_off": "Nonaktif", "prefs.theme_follow_stop": "Berhenti mengikuti", "prefs.theme_import_hint": "Klik untuk memilih file tema, seret satu ke sini, atau tekan Ctrl+V (Cmd+V di Mac) setelah menyalin tema", + "prefs.theme_option_opacity": "Opasitas", + "prefs.theme_option_opacity_help": "Atur opasitas panel mengambang. Batas minimum keterbacaan melindungi teks di atas warna viewport.", + "prefs.theme_option_tab_colours": "Warna tab", + "prefs.theme_option_tab_colours_help": "Pakai warna panel dan viewport tema ini untuk setiap tab ribbon.", + "prefs.theme_option_tab_fade_ms": "Transisi tab", + "prefs.theme_option_tab_fade_ms_help": "Memudarkan warna panel dan viewport saat berpindah tab. Nol berpindah seketika.", + "prefs.theme_options_badge": "Opsi", + "prefs.theme_options_more": "Lainnya · {count}", + "prefs.theme_options_reset": "Reset", + "prefs.theme_options_unavailable": "Tidak tersedia di perangkat ini", "prefs.theme_paste": "Tempel tema", "prefs.theme_paste_none": "Papan klip tidak berisi tema SolidCraft", "prefs.timeline_half_hour": "−30 mnt", @@ -2077,6 +2356,8 @@ "prefs.under_cursor": "Di bawah kursor", "prefs.units_precision": "Satuan dan presisi", "prefs.updates": "Pembaruan", + "prefs.value_boxes_in_view": "Tampilkan nilai di samping handle-nya di tampilan", + "prefs.value_boxes_in_view_tip": "Nilai sebuah perintah (Jarak, Radius…) dalam kotak di samping handle-nya, di atas model. Mati: Anda mengetiknya di jendela perintah, yang menerima keyboard setelah pemilihan, dan tidak ada yang menutupi model.", "prefs.view": "Perilaku tampilan", "prefs.view_confirm_corner": "OK dan Batal di sudut tampilan", "prefs.view_confirm_corner_note": "Saat dialog atau sketsa terbuka, tanda centang dan tanda silang di kanan atas tampilan melakukan hal yang sama seperti OK dan Batal pada dialog, atau menyelesaikan dan membatalkan sketsa. Navigator tampilan bergeser ke bawahnya.", @@ -2086,6 +2367,12 @@ "prefs.viewport_background": "Latar belakang", "prefs.viewport_background_colour": "Warna", "prefs.viewport_background_theme": "Gradasi tema", + "prefs.viewport_section_status": "Tampilkan status penampang viewport", + "prefs.viewport_section_status_help": "Tampilkan pengaturan penampang di viewport. Escape dan tombol ribbon yang aktif mematikannya. Matikan untuk mempertahankan kontrol penampang sebelumnya.", + "prefs.viewport_status_details": "Detail viewport langsung", + "prefs.viewport_status_details_help": "Edit pengaturan analisis dan penampang yang aktif di kartu detail standar. Matikan untuk memulihkan tombol analisis hanya-baca dan dialog Penampang.", + "prefs.viewport_status_summary": "Lipat item status viewport", + "prefs.viewport_status_summary_help": "Lipat tiga item status viewport atau lebih menjadi satu lencana ringkasan. Matikan untuk mempertahankan setiap item di baris penuh.", "prefs.wheel": "Roda", "prefs.wheel_trackpad": "Roda dan trackpad", "prefs.workspace": "Workspace", @@ -2964,7 +3251,9 @@ "render.output_ready": "Siap dirender", "render.output_run_check": "Jalankan pemeriksaan", "render.output_size_limit": "{method}: hingga {dimension} px per sisi dan {pixels} piksel. Ukuran terbesar yang muat: {width} × {height}.", + "render.output_size_method_profile": "Untuk merender {width} × {height}, pilih {method} dengan profil sumber daya CPU {profile}, atau ukuran yang lebih kecil.", "render.output_size_ok": "Ukuran OK · {width} × {height} px", + "render.output_size_profile": "Untuk merender {width} × {height}, pilih profil sumber daya CPU {profile}, atau ukuran yang lebih kecil.", "render.output_swap_orientation": "Tukar lebar dan tinggi", "render.output_tools_inspector": "Edit ukuran dan format di Inspektor Keluaran.", "render.output_view_image": "Lihat gambar", @@ -3177,6 +3466,7 @@ "render.size_custom": "Kustom…", "render.size_full_hd": "Full HD", "render.size_hd": "HD", + "render.size_needs_profile": "{size} · memerlukan {profile}", "render.size_presets": "Preset ukuran", "render.size_presets_pending": "Hadir bersama editor Keluaran", "render.size_preview": "Pratinjau", @@ -3464,6 +3754,24 @@ "sheet.flange_type_contour": "Kontur", "sheet.flange_type_edge": "Tepi", "sheet.no_body": "tidak ada bodi pelat logam: mulai satu dengan Flens > Base, atau ubah pelat menjadi pelat logam dengan Konversi ke Pelat Logam (pilih muka datar besarnya)", + "sheet.personal_rules.confirm_delete": "Konfirmasi hapus", + "sheet.personal_rules.delete": "Hapus", + "sheet.personal_rules.delete_prompt": "Hapus aturan pribadi ini? Aturan yang sudah ada di desain tidak berubah.", + "sheet.personal_rules.full": "Pustaka pribadi sudah berisi 64 aturan.", + "sheet.personal_rules.invalid_name": "Masukkan nama singkat tanpa jeda baris atau karakter kontrol.", + "sheet.personal_rules.invalid_values": "Lengkapi nilai aturan: masing-masing harus dapat dihitung di desain ini, dengan ketebalan positif dan faktor K dari 0 sampai 1.", + "sheet.personal_rules.missing": "Aturan pribadi terpilih sudah tidak tersedia.", + "sheet.personal_rules.name": "Nama aturan", + "sheet.personal_rules.name_exists": "Aturan pribadi dengan nama ini sudah ada. Gunakan nama lain.", + "sheet.personal_rules.name_hint": "Nama yang unik", + "sheet.personal_rules.overwrite": "Simpan perubahan ke aturan pribadi terpilih", + "sheet.personal_rules.rename": "Ganti nama", + "sheet.personal_rules.rule": "Aturan pribadi", + "sheet.personal_rules.save": "Simpan baru", + "sheet.personal_rules.scope": "Disimpan di profil Anda untuk semua desain.", + "sheet.personal_rules.select": "Pilih aturan", + "sheet.personal_rules.title": "Aturan pribadi", + "sheet.personal_rules.unsupported": "Aturan ini memiliki nilai yang tidak dapat dipakai versi ini.", "shortcut.json_error": "parameter harus berupa JSON: {error}", "shortcut.no_command": "tidak ada perintah `{id}`; teks sebelum JSON harus berupa id perintah", "shortcut.pin": "Sematkan di atas kotak S", @@ -3500,6 +3808,7 @@ "sketch_text.align_right": "Kanan", "sketch_text.bold": "Tebal", "sketch_text.builtin_font": "{family} (bawaan)", + "sketch_text.formula": "Mulai dengan = untuk menyusun kata dari parameter: ='W' + number(Width)", "sketch_text.italic": "Miring", "sketch_text.loading_fonts": "Mencari font terpasang…", "sketch_text.on_path": "Teks mengikuti jalurnya: kurva yang menempatkannya", @@ -3640,6 +3949,8 @@ "ui.3d_mouse_section": "Mouse 3D", "ui.3d_mouse_show_in_view": "Tampilkan mouse 3D di tampilan 3D", "ui.3d_mouse_show_in_view_tip": "Gambar mouse 3D Anda di sudut kiri bawah, bergerak mengikuti gerakan Anda.", + "ui.3d_mouse_sketch_orbit": "Putar sketsa dengan mouse 3D", + "ui.3d_mouse_sketch_orbit_tip": "Mouse 3D juga memutar tampilan saat sketsa terbuka dan dilihat tegak lurus, seperti yang dapat dilakukan mouse biasa. Mati: mouse 3D hanya menggeser dan men-zoom sketsa sampai Anda memiringkan tampilan.", "ui.3d_mouse_skip": "Perangkat saya tidak bisa ini", "ui.3d_mouse_skip_tip": "Melewati gerakan ini ke kedua arah, dan kalibrasi tidak menyertakannya.", "ui.3d_mouse_speed": "Kecepatan mouse 3D", @@ -3708,6 +4019,7 @@ "ui.agent_chat_auth_terminal": "Jalankan ini di terminal, lalu tekan Hubungkan lagi:", "ui.agent_chat_auth_title": "Masuk ke agen", "ui.agent_chat_cancel": "Batal", + "ui.agent_chat_cannot_start": "Terpasang, tetapi tidak dapat dimulai: {error}", "ui.agent_chat_changes": "Perubahan: {n}, satu langkah urung", "ui.agent_chat_changes_n": "{n} perubahan", "ui.agent_chat_changes_of": "{n}/{limit} perubahan", @@ -3800,9 +4112,12 @@ "ui.agent_chat_queued_stopped": "Mengantre (dihentikan, belum dikirim)", "ui.agent_chat_refresh": "Segarkan", "ui.agent_chat_refused": "Tidak diizinkan untuk agen: {reason}", + "ui.agent_chat_reinstall": "{agent} terpasang ({program}), tetapi tidak dapat dimulai saat SolidCraft memeriksanya: {error}\nInstalasinya mungkin tidak lengkap, atau dibuat untuk jenis komputer lain. Pasang lagi dengan `{install}`, periksa bahwa ia menampilkan versinya di terminal, lalu tekan Segarkan.", "ui.agent_chat_remove": "Hapus dari antrean", "ui.agent_chat_remove_file": "Lepaskan dari pesan ini", "ui.agent_chat_remove_imported": "Lepaskan dari pesan ini; part tetap berada di desain (Urungkan menghapusnya)", + "ui.agent_chat_reply_cut_off": "Balasan mencapai batas panjangnya sebelum selesai, jadi mungkin tidak lengkap. Minta agen untuk melanjutkan, atau untuk jawaban yang lebih singkat.", + "ui.agent_chat_reply_declined": "Model menolak menjawab pesan ini.", "ui.agent_chat_retry": "Coba lagi", "ui.agent_chat_right": "Kanan", "ui.agent_chat_save": "Simpan", @@ -3861,12 +4176,17 @@ "ui.ai_copy_suffix": "salinan", "ui.ai_custom_url": "URL kustom", "ui.ai_default": "Default", + "ui.ai_edit": "Edit", + "ui.ai_edit_agent_title": "Edit agen", + "ui.ai_edit_gone": "Agen ini telah dihapus, jadi tidak ada yang perlu disimpan.", + "ui.ai_edit_tip": "Ubah nama, perintah, atau endpoint agen ini. Key yang tersimpan tetap tersembunyi.", "ui.ai_http_busy": "endpoint sibuk atau melebihi batas; coba lagi nanti", "ui.ai_http_key": "endpoint menolak key: periksa di Preferensi > AI", "ui.ai_http_not_found": "endpoint atau model tidak ada: periksa URL dasar dan nama model", "ui.ai_http_too_large": "percakapan terlalu besar untuk endpoint ini; mulai obrolan baru", "ui.ai_key": "Key API", "ui.ai_key_hint": "opsional; disimpan di keychain sistem", + "ui.ai_key_keep_hint": "kosongkan untuk mempertahankan key yang tersimpan", "ui.ai_key_needs_https": "Key hanya dikirim lewat HTTPS, atau ke komputer ini.", "ui.ai_key_note": "Disimpan di keychain sistem, tidak pernah di pengaturan SolidCraft. Kosongkan untuk server lokal.", "ui.ai_keychain_error": "Keychain sistem menolak: {error}", @@ -3898,6 +4218,7 @@ "ui.ai_request_timeout": "Batas waktu permintaan", "ui.ai_request_timeout_hint": "Berapa lama satu permintaan model ke endpoint ini boleh berlangsung, dari 30 s sampai satu jam. Naikkan untuk model lokal yang lambat.", "ui.ai_reset": "Reset", + "ui.ai_save": "Simpan", "ui.ai_settings": "Pengaturan AI…", "ui.ai_show_features": "Tampilkan fitur AI", "ui.ai_show_features_note": "Chat agen: panel tempat agen coding yang sudah Anda pakai, atau endpoint model pilihan Anda, bekerja pada desain yang terbuka. Nonaktif: tidak ada tombol AI, pintasan, entri menu, atau panel, dan tidak ada yang dicari atau dimulai.", @@ -4071,6 +4392,40 @@ "ui.design_properties.project": "Proyek", "ui.design_properties.revision": "Revisi", "ui.design_saved": "Disimpan ke {path}", + "ui.details_access_caption": "Muka menunjukkan apakah alat dapat menjangkaunya dari arah yang dipilih.", + "ui.details_angle": "Sudut", + "ui.details_continuity_caption": "Edit jahitan terpilih. Jahitan lain mempertahankan pengaturannya sendiri.", + "ui.details_continuity_stale": "Geometri sebelumnya: jalankan ulang analisis untuk mengedit jahitan ini atau mengepaskan tampilan padanya.", + "ui.details_copy_list": "Salin daftar", + "ui.details_curvature": "Toleransi kelengkungan", + "ui.details_curvature_caption": "Muka diberi warna menurut radius kelengkungan lokalnya.", + "ui.details_direction": "Arah alat", + "ui.details_draft_caption": "Muka diberi warna menurut sudutnya terhadap arah tarik.", + "ui.details_edit_seam": "Edit jahitan", + "ui.details_fixture": "Spesimen komponen — hanya data contoh", + "ui.details_fixture_caption": "Spesimen komponen dengan nilai ilustratif; ini bukan hasil model.", + "ui.details_fixture_problem": "Contoh temuan dengan masalah", + "ui.details_fixture_readout": "Contoh nilai terukur", + "ui.details_flip": "Balik", + "ui.details_gap": "Celah", + "ui.details_hatch": "Arsir muka potongan", + "ui.details_jump": "Lompatan kelengkungan", + "ui.details_key": "Legenda", + "ui.details_offset": "Offset", + "ui.details_plane": "Bidang", + "ui.details_pull": "Arah tarik", + "ui.details_radius": "Radius", + "ui.details_readout": "Pembacaan", + "ui.details_samples": "Sampel", + "ui.details_seam": "{body} · Tepi {edge}", + "ui.details_settings": "Pengaturan", + "ui.details_sharp_gap": "Tajam atau celah", + "ui.details_show_all": "Tampilkan semua {count}", + "ui.details_smooth": "Halus", + "ui.details_stripes": "Lajur", + "ui.details_target": "Pas ke geometri", + "ui.details_worst_seams": "Jahitan terburuk", + "ui.details_zebra_caption": "Lajur terpantul menunjukkan seberapa mulus muka bertemu.", "ui.diameter": "Diameter", "ui.dimension": "Dimensi", "ui.direct_interaction": "Interaksi langsung", @@ -4131,6 +4486,7 @@ "ui.equation_y_of_x": "y(x)", "ui.equivalent_radius": "Radius Ekuivalen", "ui.expand": "Perluas", + "ui.explode_needs_two_components": "Tampilan Urai menarik komponen terpisah, jadi desain memerlukan dua komponen atau lebih (Komponen Baru, Sisipkan Desain, atau Buat Komponen dari Bodi)", "ui.exportDxf": "Ekspor DXF…", "ui.export_ellipsis_keymap": "Ekspor…", "ui.export_keymap_tip": "Tombol, favorit kotak S, dan toolbar, sebagai file", @@ -4419,12 +4775,17 @@ "ui.plugins_skipped": "{count} folder tidak dapat dibaca.", "ui.plugins_version": "{version}", "ui.point": "Titik", + "ui.point_cloud_add_filter": "Tambah filter…", "ui.point_cloud_auto_range": "Rentang Otomatis", "ui.point_cloud_color_by": "Warnai Menurut", "ui.point_cloud_file_colors": "Warna File", + "ui.point_cloud_filter_by": "Filter menurut", "ui.point_cloud_height_high": "Tertinggi", "ui.point_cloud_height_low": "Terendah", + "ui.point_cloud_legend_filter": "Ditampilkan dan disembunyikan di legenda di atas", "ui.point_cloud_no_file_colors": "File tidak memiliki warna", + "ui.point_cloud_pause": "Jeda", + "ui.point_cloud_play": "Putar", "ui.point_cloud_single_color": "Satu Warna", "ui.point_cloud_size": "Ukuran Titik", "ui.point_cloud_units": "Satuan File", @@ -4453,6 +4814,7 @@ "ui.properties.inertia_y": "Iyx, Iyy, Iyz", "ui.properties.inertia_z": "Izx, Izy, Izz", "ui.properties.measuring": "Mengukur…", + "ui.properties.measuring_bodies": "Mengukur… {done} dari {total} bodi", "ui.properties.measuring_failed": "Pengukuran berhenti sebelum selesai.", "ui.properties.principal_axis": "Sumbu utama {number}", "ui.properties.principal_moments": "Momen utama", @@ -4520,6 +4882,7 @@ "ui.selectOther": "Pilih Lainnya…", "ui.selectTangentChain": "Pilih Rantai Tangen", "ui.select_all": "Pilih Semua", + "ui.select_components_by_face": "Pilih komponen (sebuah muka memilih komponennya)", "ui.selection": "Pilihan", "ui.selection_modifiers": "Pengubah pilihan", "ui.selection_shift_add_only": "Shift+klik menambah tanpa menghapus", @@ -4561,6 +4924,11 @@ "ui.sketch_intersect_selection_filter": "Filter Pilihan", "ui.sketch_modifiers": "Pengubah sketsa", "ui.sketch_project_geometry": "Geometri", + "ui.sketch_report.all_constrained": "Semuanya terbatasi penuh.", + "ui.sketch_report.dof": "Derajat kebebasan", + "ui.sketch_report.loose": "Belum terbatasi penuh", + "ui.sketch_report.status": "Status", + "ui.sketch_report.title": "Laporan Sketsa", "ui.sketch_scale": "Skala Sketsa", "ui.sketches": "Sketsa", "ui.slice": "Iris", @@ -4670,7 +5038,14 @@ "ui.viewport_status_key": "Tampilkan legenda untuk {name}", "ui.viewport_status_minimum_radius": "Radius minimum", "ui.viewport_status_pairs": "{count} pasang", + "ui.viewport_status_section_hatch": "{setting} · arsir", + "ui.viewport_status_section_plane": "Bidang saat ini", + "ui.viewport_status_section_tilted": "miring · {offset}", "ui.viewport_status_stripes": "{count} lajur", + "ui.viewport_status_summary_clear": "Matikan semuanya", + "ui.viewport_status_summary_heading": "Di tampilan ini", + "ui.viewport_status_summary_label": "{name} dan {count} item lainnya", + "ui.viewport_status_summary_more": "+{count}", "ui.viewport_theme_follow": "Ikuti tema", "ui.visual_shaded_hidden_edges": "Shaded dengan tepi tersembunyi", "ui.visual_shaded_with_edges": "Shaded dengan tepi", diff --git a/assets/locales/it.json b/assets/locales/it.json index 027040b04b..948af776e9 100644 --- a/assets/locales/it.json +++ b/assets/locales/it.json @@ -60,6 +60,7 @@ "BLEND SURFACE": "SUPERFICIE DI RACCORDO", "BOUNDARY SURFACE": "SUPERFICIE DA CONTORNI", "BROWSER": "STRUTTURA", + "Back to the original order": "Torna all'ordine originale", "Ball": "Sferico", "Bar": "Barra", "Base Center": "Centro della base", @@ -595,6 +596,8 @@ "Snap to face and edge centres": "Aggancia ai centri di facce e spigoli", "SolidCraft cannot submit reports directly. Submit on GitHub; no token is needed in the app.": "SolidCraft non può inviare direttamente le segnalazioni. Inviale su GitHub; nell'app non serve alcun token.", "SolidCraft closed before these designs were saved. Their last autosave can be opened.": "SolidCraft si è chiuso prima del salvataggio di questi progetti. È possibile aprire il loro ultimo salvataggio automatico.", + "Sort A to Z": "Ordina dalla A alla Z", + "Sort Z to A": "Ordina dalla Z alla A", "Spacing 2 must be a non-zero number": "La spaziatura 2 deve essere un numero diverso da zero", "Spacing must be a non-zero number": "La spaziatura deve essere un numero diverso da zero", "Specialise": "Specializza", @@ -819,6 +822,7 @@ "cmd.appearance_pick_up": "Preleva aspetto", "cmd.appearance_replace": "Sostituisci aspetto", "cmd.assembly_bom": "Distinta base", + "cmd.assembly_mass_properties": "Proprietà di massa", "cmd.body.print_face": "Faccia di stampa", "cmd.body_rename": "Rinomina corpo", "cmd.browser_collapse_all": "Comprimi tutto", @@ -1244,6 +1248,7 @@ "cmd.view.render_browser": "Struttura del rendering", "cmd.view.render_dock": "Sposta pannello di rendering", "cmd.view.render_focus": "Vista focalizzata sul rendering", + "cmd.view.status_details": "Dettagli di stato della vista", "cmd.view.status_items": "Elementi di stato della vista", "cmd.view_ambient_occlusion": "Occlusione ambientale", "cmd.view_angle_readout": "Angoli della vista", @@ -1282,8 +1287,14 @@ "color.hex_hint": "#rrggbb", "color.hue": "Tonalità", "color.linear": "Valori del colore mostrati nello spazio lineare 0-1", + "color.recent": "Colori recenti", + "color.recent_help": "I colori scelti in qualsiasi campo colore compaiono qui", + "color.saturation": "Saturazione (percentuale)", "color.saturation_value": "Saturazione e valore", "color.selected": "Colore selezionato", + "color.step_down": "Diminuisci di un passo", + "color.step_up": "Aumenta di un passo", + "color.value": "Valore, la luminosità (percentuale)", "config.column_filter_empty": "Nessuna colonna corrispondente", "contact.components_count": "{count} componenti", "contact.enabled_label": "abilitato", @@ -1387,21 +1398,168 @@ "from the rule": "dalla regola", "help.apache": "Licenza Apache 2.0", "help.appearance.assign": "Dà ai corpi, alle facce o ai componenti selezionati un colore o un aspetto di materiale, senza cambiarne il peso. Usalo per distinguere le parti o per vedere in anteprima una finitura.", + "help.assembly.bom": "Elenca le parti del progetto o di un componente con le relative quantità, per numero di parte, e può salvare l'elenco come CSV o JSON. Usalo come distinta delle parti per ordini e montaggio.", "help.attributions": "Attribuzioni (ATTRIBUTION.md)", "help.build": "Versione {version} · commit {commit} · compilata il {date}", + "help.canvas.decal": "Applica un'immagine su una faccia piana del modello, come un adesivo. Usalo per mostrare un'etichetta, un logo o un motivo stampato su un pezzo.", + "help.canvas.edit": "Modifica un'immagine di riferimento: posizione, dimensione, angolo, opacità, specchiatura, visibilità o nome. Usalo per allineare l'immagine allo schizzo che ci ricalchi sopra.", + "help.canvas.insert": "Colloca un'immagine PNG o JPEG su un piano o una faccia come riferimento semitrasparente. Usalo per ricalcare una foto o un disegno in uno schizzo, con l'immagine scalata a una dimensione nota.", + "help.component.break_link": "Interrompe il collegamento di un componente con il suo file di progetto, mantenendone i corpi così come sono. Usalo quando la parte non deve più seguire le modifiche del file originale.", + "help.component.capture_position": "Acquisisce le posizioni delle parti spostate a mano, che diventano la loro nuova posizione. Usalo dopo aver trascinato in posizione parti che nessun giunto trattiene.", + "help.component.create": "Aggiunge al progetto un nuovo componente vuoto, dentro il componente attivo a meno che tu non scelga un altro padre. Usalo per dare a una nuova parte di un assieme il proprio spazio per corpi e schizzi.", "help.component.finish_edit": "Salva la parte che stai modificando sul posto, torna al suo assieme e lo aggiorna con le modifiche. Usalo quando hai finito di modificare la parte collegata.", + "help.component.from_bodies": "Trasforma ogni corpo selezionato in un componente a sé, con il nome del corpo. Usalo quando un progetto costruito con corpi sciolti deve diventare un assieme le cui parti possono essere collegate con giunti e spostate.", + "help.component.insert_design": "Inserisce un altro progetto salvato come componente collegato al suo file, con i suoi corpi solidi posizionati come nel file originale. Usalo per riutilizzare una parte in un assieme e recepire le modifiche successive con Aggiorna collegamenti.", + "help.component.properties": "Mostra volume, massa, centro di massa e momenti d'inerzia di un componente, in base ai materiali dei suoi corpi. Usalo per controllare peso e bilanciamento di un assieme.", + "help.component.revert_position": "Riporta dove erano le parti spostate a mano, scartando gli spostamenti non acquisiti. Usalo per annullare gli spostamenti di prova in un assieme.", + "help.component.update_links": "Rilegge i file di progetto collegati e sostituisce i corpi che sono cambiati. Usalo dopo aver modificato una parte usata da questo assieme.", + "help.config.configure": "Aggiunge parametri, lavorazioni, materiali o aspetti come colonne della tabella delle configurazioni. Usalo per creare varianti di uno stesso progetto, ad esempio le misure di un pezzo.", + "help.config.preview": "Verifica che una riga della tabella delle configurazioni si ricostruisca, senza modificare il progetto né la sua cronologia. Usalo prima di passare a una variante di cui non sei sicuro.", + "help.config.rules": "Mostra le regole della tabella delle configurazioni, le sue righe e quale riga è attiva. Usalo per vedere come sono impostate le varianti di un progetto.", + "help.config.table": "Mostra la tabella delle configurazioni: una riga per variante, una colonna per parametro o lavorazione, e se il progetto corrisponde ancora alla riga attiva. Usalo per esaminare le varianti di un progetto.", "help.connect_note": "Riavvia ogni assistente per caricare il server SolidCraft. Ogni file è stato salvato come .bak prima della modifica.", "help.connect_title": "Collega assistenti AI", + "help.construct.axis.edge": "Crea un asse di costruzione lungo uno spigolo rettilineo o una linea dello schizzo. Usalo come asse per una rivoluzione, una serie circolare o un giunto.", + "help.construct.axis.normal_to_face": "Crea un asse di costruzione passante per un punto, perpendicolare a una faccia. Usalo come asse che esce dritto da una superficie.", + "help.construct.axis.through_cylinder": "Crea un asse di costruzione passante per il centro di una faccia cilindrica, conica o torica, o di uno spigolo circolare. Usalo per ruotare lavorazioni o ripeterle in serie attorno a un pezzo tondo.", + "help.construct.axis.two_planes": "Crea un asse di costruzione dove si incontrano due piani o facce piane. Usalo quando l'asse che ti serve è la linea tra due facce.", + "help.construct.axis.two_points": "Crea un asse di costruzione passante per due punti, vertici o punti dello schizzo. Usalo per un asse che collega due posizioni note.", + "help.construct.plane.along_path": "Crea un piano di costruzione perpendicolare a uno spigolo o a una curva, in una posizione lungo di essa. Usalo per disegnare il profilo di uno sweep o una sezione di un percorso.", + "help.construct.plane.at_angle": "Crea un piano di costruzione ruotato di un angolo rispetto a un piano o a una faccia, attorno a un asse o a uno spigolo. Usalo per schizzi inclinati, ad esempio una staffa angolata.", + "help.construct.plane.midplane": "Crea un piano di costruzione a metà tra due piani o facce piane, oppure che li biseca dove si incontrano ad angolo. Usalo come piano di simmetria per specchiare.", + "help.construct.plane.offset": "Crea un piano di costruzione parallelo a un piano o a una faccia piana, a una certa distanza o passante per un punto. Usalo per disegnare a un'altezza stabilita sopra una faccia.", + "help.construct.plane.perpendicular": "Crea un piano di costruzione che contiene una linea o uno spigolo ed è perpendicolare a un piano o a una faccia. Usalo per disegnare in verticale sul modello lungo uno spigolo.", + "help.construct.plane.tangent": "Crea un piano di costruzione tangente a un cilindro, un cono, una sfera o un toro in un punto. Usalo per disegnare sul lato di un pezzo tondo.", + "help.construct.plane.three_points": "Crea un piano di costruzione passante per tre punti. Usalo per un piano definito da posizioni sul modello, ad esempio tre angoli.", + "help.construct.plane.two_edges": "Crea un piano di costruzione passante per due spigoli rettilinei o linee che giacciono sullo stesso piano. Usalo per un piano che attraversa un pezzo, ad esempio per due spigoli opposti.", + "help.construct.point.along_path": "Crea un punto di costruzione in una posizione lungo uno spigolo o una curva. Usalo come riferimento in un punto intermedio di un percorso.", + "help.construct.point.center": "Crea un punto di costruzione al centro di uno spigolo circolare, di un cerchio o arco dello schizzo, di una sfera o di un toro. Usalo come riferimento per fori, assi e giunti.", + "help.construct.point.edge_plane": "Crea un punto di costruzione dove uno spigolo o una linea attraversa un piano o una faccia piana. Usalo per segnare dove un percorso incrocia un piano.", + "help.construct.point.three_planes": "Crea un punto di costruzione dove si incontrano tre piani o facce piane. Usalo come riferimento d'angolo, anche dove il modello non ha un vertice.", + "help.construct.point.two_edges": "Crea un punto di costruzione dove due spigoli o linee si incontrano, o si avvicinano di più. Usalo come riferimento in un angolo virtuale.", + "help.construct.point.vertex": "Crea un punto di costruzione su un vertice o su un punto dello schizzo. Usalo come riferimento che resta anche quando lo schizzo o il corpo intorno è nascosto.", + "help.contact.check": "Elenca le coppie di parti nei gruppi di contatto che si compenetrano nella posizione attuale. Usalo per trovare le collisioni prima di azionare i giunti o avviare uno studio del movimento.", + "help.contact.create": "Raggruppa due o più parti in un gruppo di contatto, i cui corpi non possono compenetrarsi mentre i giunti si muovono. Usalo per parti che devono fermarsi quando si toccano, come una leva contro il suo fermo.", + "help.contact.disable_all": "Disattiva il contatto, così i giunti azionati e gli studi del movimento non si fermano più dove le parti si toccano. Usalo per muovere di nuovo le parti liberamente, o per mantenere reattivo un assieme grande.", + "help.contact.edit": "Modifica un gruppo di contatto: le parti che contiene, il nome o se è sospeso; può anche eliminare il gruppo. Usalo per regolare quali parti non possono compenetrarsi.", + "help.contact.enable_all": "Mette ogni parte dell'assieme in un unico gruppo di contatto, così nessuna coppia di parti può compenetrarsi mentre i giunti si muovono. Usalo per un controllo rapido di un intero meccanismo.", + "help.contact.enable_sets": "Attiva il contatto per i gruppi di contatto che hai definito, così i giunti azionati e gli studi del movimento si fermano dove le loro parti si compenetrerebbero. Usalo per riattivare i tuoi gruppi dopo aver disattivato il contatto.", + "help.contact.list": "Elenca i gruppi di contatto del progetto, le parti di ciascuno e se il contatto è attivo. Usalo per controllare come è impostato il contatto prima di azionare l'assieme.", + "help.document.inspect": "Mostra una panoramica del progetto: componenti, corpi, schizzi, lavorazioni e parametri. Usalo per orientarti in un progetto che non hai realizzato tu.", + "help.document.units": "Cambia le unità in cui il progetto mostra le lunghezze, come mm o pollici. La geometria mantiene le sue dimensioni; si adattano solo la visualizzazione e i numeri semplici.", + "help.edit.checkpoint": "Memorizza il progetto, lo schizzo attivo e la cronologia di annullamento così come sono ora. Usalo prima di tentare qualcosa di rischioso da cui potresti voler tornare indietro.", + "help.edit.restore_checkpoint": "Torna a un punto di ripristino, compresa la cronologia di annullamento. Usalo per scartare in un solo passo tutto ciò che è stato fatto dopo il punto di ripristino.", + "help.explode.create": "Crea una vista esplosa che allontana le parti dal centro dell'assieme, oppure con gli spostamenti che imposti. Usalo per mostrare come si monta un assieme, per istruzioni o presentazioni.", "help.face.delete": "Rimuove le facce selezionate e lascia che le facce vicine chiudano il vuoto, oppure lascia il vuoto aperto come superficie. Usalo per togliere uno spigolo arrotondato, un foro o una sporgenza che è d'intralcio.", + "help.file.insert_mesh": "Aggiunge al progetto un file 3MF, STL, OBJ, PLY o glTF come corpi a mesh in un nuovo componente. Usalo per affiancare al progetto, come riferimento, un modello scaricato o scansionato.", + "help.file.insert_step": "Aggiunge al progetto un file STEP, IGES, FreeCAD o Rhino come corpi solidi in un nuovo componente; le parti di un assieme diventano componenti annidati. Usalo per importare modelli di fornitori o di altri programmi CAD.", + "help.file.save_mesh": "Salva i corpi come mesh STL, 3MF o OBJ, con la finezza che scegli per le facce curve. Usalo per preparare pezzi per la stampa 3D o per programmi che leggono mesh.", + "help.file.send_to_slicer": "Salva i corpi selezionati come file 3MF e lo apre nello slicer che scegli. Usalo per passare dal modello alla stampa in un solo passo.", "help.font_details": "L’interfaccia usa i caratteri integrati di egui (Ubuntu Light e Hack, con licenze Ubuntu Font Licence e MIT/Bitstream Vera; Noto Emoji e il carattere per icone emoji, con licenze OFL e MIT). Il testo degli schizzi usa Liberation Sans (SIL OFL 1.1). Il pacchetto di caratteri facoltativo di The Crafting Apps, craft-fonts (BIZ UDPGothic, Shippori Mincho, BIZ UDMincho, Noto Sans CJK SC, Noto Sans Arabic; SIL OFL 1.1), non è incluso nelle build di SolidCraft.", "help.fonts": "Caratteri", + "help.frame.member": "Posiziona un profilo, come un tubo o un profilato di alluminio, lungo le linee di uno schizzo del telaio, un membro per linea. Usalo per costruire telai, scaffalature e strutture saldate.", + "help.inspect.accessibility": "Colora le facce che un utensile può raggiungere da una direzione e quelle che non può raggiungere. Usalo per verificare un pezzo per la lavorazione meccanica o lo stampaggio da quel lato.", + "help.inspect.center_of_mass": "Mostra il centro di massa dei corpi, ciascuno da solo e tutti insieme, a densità uniforme. Usalo per controllare il bilanciamento e dove un pezzo si ribalterà.", + "help.inspect.colored_components": "Dà a ogni componente un proprio colore temporaneo, senza modificare gli aspetti. Usalo per distinguere a colpo d'occhio le parti di un assieme.", + "help.inspect.compare": "Confronta due corpi e mostra dove uno ha materiale che l'altro non ha, con volume e scostamento. Usalo per verificare un pezzo modificato o importato rispetto all'originale.", + "help.inspect.continuity": "Verifica come si raccordano le facce lungo i loro spigoli: giochi, spigoli vivi o salti di curvatura, e colora le giunzioni. Usalo per trovare dove le superfici non si raccordano bene.", + "help.inspect.curvature_comb": "Disegna pettini lungo curve, spigoli o facce i cui denti mostrano quanto si curvano. Usalo per individuare zone piatte, spigoli vivi e curvatura irregolare.", + "help.inspect.curvature_map": "Colora le facce in base a quanto si curvano, in rosso dove il raggio è piccolo. Usalo per trovare i punti stretti e valutare quanto è liscia una superficie.", + "help.inspect.draft": "Colora le facce in base all'angolo di sformo rispetto a una direzione di estrazione, segnalando quelle con sformo insufficiente. Usalo per controllare un pezzo prima dello stampaggio o della fusione.", + "help.inspect.environment_map": "Mostra il modello con una finitura riflettente, simile a uno specchio. Usalo per valutare l'andamento delle superfici dal modo in cui scorrono i riflessi.", + "help.inspect.interference": "Trova i corpi che si sovrappongono e mostra ogni sovrapposizione con il suo volume. Usalo per controllare se in un assieme ci sono parti che si scontrano.", + "help.inspect.isocurve": "Disegna le curve isoparametriche delle facce, la griglia su cui è costruita la superficie. Usalo per vedere come scorre una superficie e dove si addensa.", + "help.inspect.measure": "Misura lunghezze, distanze, angoli, aree e raggi tra gli elementi che selezioni, oppure il volume e la massa dei corpi. Usalo per controllare dimensioni e distanze libere.", + "help.inspect.minimum_radius": "Trova il raggio di curvatura minimo di curve, spigoli o facce e dove si trova. Usalo per verificare che un utensile o un ugello possa seguire la forma.", + "help.inspect.section": "Apre la vista con un taglio lungo un piano e tratteggia le facce tagliate, senza modificare il modello. Usalo per guardare dentro pezzi e assiemi.", + "help.inspect.zebra": "Mostra strisce bianche e nere riflesse sulle facce del modello. Usalo per valutare la levigatezza: strisce che si spezzano o si piegano su uno spigolo indicano una transizione scadente.", + "help.joint.as_built": "Collega due parti dove si trovano già, senza spostarne nessuna, e imposta come possono ancora muoversi. Usalo per parti che hai già posizionato nella loro posizione finale.", + "help.joint.cam": "Collega una camma rotante a un seguicamma, che scorre o oscilla mentre il profilo della camma gli passa sotto. Usalo per camme, eccentrici e comandi delle valvole.", + "help.joint.create": "Collega due parti alle origini del giunto che selezioni e sposta la seconda parte al suo posto; il tipo di giunto stabilisce come possono ancora muoversi. Usalo per costruire un assieme le cui parti si muovono come il meccanismo reale.", + "help.joint.drive": "Imposta l'angolo o la distanza di un giunto e sposta di conseguenza le parti, entro i suoi limiti; i giunti collegati e le catene cinematiche chiuse seguono il movimento. Usalo per verificare che un meccanismo si muova come previsto.", + "help.joint.gear": "Collega due giunti rotanti con un rapporto di trasmissione, così azionarne uno fa ruotare l'altro. Usalo per ingranaggi cilindrici e dentature interne, con il rapporto ricavato dai raggi primitivi o dal numero di denti.", + "help.joint.limits": "Imposta fin dove un giunto può ruotare o scorrere, con un minimo e un massimo, oppure rimuove questi limiti. Usalo per mantenere una cerniera, un cursore o una leva entro la sua reale corsa.", + "help.joint.motion_link": "Accoppia due giunti con un rapporto e uno scostamento, così azionarne uno muove l'altro. Usalo per una relazione fissa tra giunti che Ingranaggio, Pignone e cremagliera o Vite non coprono.", + "help.joint.origin": "Colloca un'origine del giunto con nome su una parte: al centro di una faccia o di un'estremità tonda, a metà di uno spigolo, su un angolo o su un punto. Usalo per preparare i punti in cui si attaccano i giunti; un'origine scelta su una faccia segue la faccia quando la parte cambia dimensione.", + "help.joint.rack_pinion": "Collega un pignone rotante a una cremagliera scorrevole, così la cremagliera si sposta della lunghezza di circonferenza primitiva percorsa dal pignone. Usalo per trasmissioni a cremagliera, sterzi e slitte lineari.", + "help.joint.reach": "Sposta un punto di una parte verso una destinazione modificando i giunti della sua catena, entro i loro limiti. Usalo per posizionare un meccanismo in base a dove deve arrivare la sua estremità, come la punta di un braccio robotico.", + "help.joint.rigid_group": "Blocca insieme le parti selezionate così come sono, in modo che si muovano come un tutt'uno. Usalo per parti che stanno insieme e non hanno moto relativo.", + "help.joint.screw": "Collega rotazione e scorrimento tramite un passo, così ogni giro avanza di quella distanza. Usalo per viti madri, bulloni e alberi filettati.", + "help.library.apply": "Applica un materiale o un aspetto da un file di libreria locale a corpi, facce o componenti. Usalo per riutilizzare materiali salvati da un altro progetto.", + "help.library.export": "Salva i materiali e gli aspetti del progetto in un file di libreria. Usalo per condividerli o riutilizzarli in altri progetti.", + "help.library.inspect": "Controlla un file di libreria ed elenca i suoi materiali e aspetti, senza modificare il progetto. Usalo per vedere cosa contiene una libreria prima di applicarla.", "help.licences": "Licenze", "help.material.assign": "Imposta di che cosa sono fatti i corpi selezionati, per esempio acciaio, alluminio o ABS. Usalo perché massa e peso corrispondano al pezzo reale.", + "help.material.properties.validate": "Controlla la coerenza dei numeri e i valori mancanti in una scheda dati di un materiale. Usalo prima di affidarti ai dati per massa o rigidità.", + "help.mesh.convert": "Ricostruisce un corpo a mesh, come un STL, come solido modificabile con facce piane e cilindriche. Usalo per modificare parti meccaniche importate come un corpo modellato.", "help.mit": "Licenza MIT", + "help.motion.study": "Imposta i valori dei giunti a intervalli lungo una sequenza temporale e li riproduce come animazione. Usalo per osservare un meccanismo muoversi in una sequenza e controllare le distanze libere lungo il percorso.", + "help.motion.trace": "Traccia il percorso seguito da un punto di una parte mentre uno studio del movimento viene riprodotto o un giunto viene azionato, e lo conserva come curva di schizzo 3D quando confermi. Usalo per vedere dove arriva un meccanismo, o per costruire a partire dal suo percorso.", "help.notice": "NOTICE", + "help.occurrence.ground": "Fissa una parte al suo posto, o la libera di nuovo, in modo che i giunti muovano le altre parti attorno a essa. Usalo per il telaio o la base su cui è costruito il resto dell'assieme.", "help.occurrence.move": "Sposta una parte dell'assieme solo lungo i gradi di libertà consentiti dai suoi giunti. Seleziona una faccia della parte, poi trascina una freccia o un arco disponibile.", "help.parameters.change": "Apre la tabella dei valori con nome e delle espressioni che guidano le quote della progettazione. Usala per cambiare una misura in un solo punto e far seguire tutte le lavorazioni che la usano.", + "help.parameters.export": "Salva i parametri del progetto come CSV o JSON. Usalo per modificarli in un foglio di calcolo o spostarli in un altro progetto.", + "help.parameters.graph": "Mostra quali parametri e lavorazioni ne usano altri, ed eventuali cicli o errori tra di loro. Usalo per vedere cosa influenzerà la modifica di un parametro.", + "help.parameters.import": "Legge i parametri da CSV o JSON nel progetto, tutti o nessuno. Usalo per importare valori da un foglio di calcolo o da un altro progetto.", + "help.parameters.move": "Sposta un parametro utente prima o dopo un altro nell'elenco. Usalo per tenere insieme i parametri correlati.", + "help.parts.fastener": "Aggiunge una vite, un bullone, un dado, una rondella, un perno o un cuscinetto standard della misura che scegli. Posizionato su un foro, si assesta nel foro con un giunto, così non devi modellare da solo gli elementi di fissaggio.", + "help.parts.insert": "Inserisce una parte standard della famiglia, misura e lunghezza che scegli. Posizionata sul bordo o sulla parete di un foro, si assesta sul foro con un giunto rigido.", + "help.parts.library": "Apre le parti standard: viti, bulloni, dadi, rondelle, grani, spine cilindriche e cuscinetti nelle misure standard. Usalo per aggiungere una parte standard invece di modellarla da solo.", + "help.parts.resize": "Cambia la misura o la lunghezza di una parte standard inserita e la ricostruisce; i suoi giunti la mantengono in sede. Usalo quando una vite risulta troppo lunga o un foro richiede un'altra misura.", + "help.plastic.assign_rule": "Assegna una regola per plastica ai corpi, così le loro lavorazioni in plastica ne usano come valori predefiniti lo sformo e il gioco. Usalo per impostare una sola volta un pezzo stampato per tutte le sue lavorazioni.", + "help.plastic.boss": "Aggiunge un risalto per vite su una faccia: un montante con foro, sformo, raccordo di base e nervature di sostegno facoltativi. Usalo per i punti di avvitamento in involucri stampati o stampati in 3D.", + "help.plastic.lip": "Aggiunge un labbro o una scanalatura corrispondente lungo il bordo di un corpo svuotato. Usalo perché due metà di un involucro si centrino tra loro e chiudano bene.", + "help.plastic.manage_rules": "Crea e modifica le regole per plastica: materiale, spessore della parete, raggi, gioco e sformo. Usale per mantenere coerenti le lavorazioni di un pezzo stampato.", + "help.plastic.rest": "Aggiunge un appoggio su una faccia: un tampone tondo o rettangolare, pieno o cavo, con sformo. Usalo come supporto o distanziale in un involucro stampato.", + "help.plastic.snap_fit": "Aggiunge un gancio a scatto su una faccia, con lunghezza, spessore e dente di aggancio. Usalo per fissare a scatto le parti senza viti.", "help.pointcloud.insert": "Inserisci una scansione PLY, XYZ o PTS come nuvola di punti accanto al modello. Usala come riferimento quando costruisci un modello a partire da dati misurati.", + "help.sheet.convert": "Trasforma una piastra o un corpo svuotato di spessore uniforme in un corpo in lamiera, con pieghe sugli spigoli che scegli. Usalo per rendere sviluppabile un pezzo esistente.", + "help.sheet.export_bend_table": "Salva la tabella delle pieghe di un corpo in lamiera come CSV o JSON: angoli, raggi e posizioni delle pieghe nello sviluppo piano. Usalo per la pressopiegatrice o per l'officina.", + "help.sheet.export_dxf": "Salva lo sviluppo piano come DXF, con contorni, linee di piega e note di piega facoltative. Usalo per il taglio laser, al plasma o a getto d'acqua.", + "help.sheet.flange": "Aggiunge una flangia: una piastra di base da uno schizzo, una parete lungo un contorno aperto dello schizzo o una parete piegata sugli spigoli della lamiera. Usalo per costruire pezzi in lamiera.", + "help.sheet.flat_pattern": "Crea lo sviluppo piano di un corpo in lamiera, con dimensioni, tagli e linee di piega. Usalo per controllare il grezzo prima di tagliarlo.", + "help.sheet.fold": "Piega una lamiera lungo una linea dello schizzo con un angolo e un raggio. Usalo per piegare una lamiera piana dopo avervi praticato i tagli.", + "help.sheet.hem": "Ripiega su se stesso il bordo di una lamiera. Usalo per bordi sicuri e rigidi, senza bave taglienti.", + "help.sheet.manage_rules": "Crea e modifica le regole per lamiera: spessore, raggio di piega, fattore K, scarichi e giochi. Usale per adattarle al materiale e agli utensili della tua officina.", + "help.sheet.path_flange": "Aggiunge una catena di flange lungo uno spigolo della lamiera, seguendo una catena aperta di linee dello schizzo. Usalo per profili a U o a Z in un solo passo.", + "help.sheet.perforate": "Aggiunge un campo di fori su tutta la lamiera piana, con disposizione sfalsata o quadrata. Usalo per prese d'aria, griglie e pannelli più leggeri.", + "help.sheet.refold": "Ripiega un corpo in lamiera sviluppato. Usalo dopo aver aggiunto lavorazioni alla lamiera piana.", + "help.sheet.unfold": "Spiega tutte le pieghe di un corpo in lamiera. Usalo per aggiungere fori o tagli che devono attraversare una piega, poi ripiega.", + "help.sketch.arc.center_point": "Disegna un arco di cerchio attorno a un punto centrale, da un punto iniziale a un punto finale. Usalo quando il centro dell'arco è noto, ad esempio un raggio attorno a un foro o a un perno.", + "help.sketch.arc.tangent": "Disegna un arco che parte dall'estremità di una linea o di un arco e prosegue tangente a essa. Usalo per continuare una catena con una curva dolce invece di un angolo vivo.", + "help.sketch.arc.three_point": "Disegna un arco da un punto iniziale a un punto finale passando per un terzo punto. Usalo quando l'arco deve passare per punti dati invece che attorno a un centro noto.", + "help.sketch.arc3d": "Disegna un arco di cerchio esatto nello spazio passante per tre punti, con Schizzo 3D attivo. Usalo per le curve di percorsi e guide che escono dal piano dello schizzo.", + "help.sketch.auto_constrain": "Aggiunge i vincoli che lo schizzo ha già quasi, come orizzontale, parallelo o coincidente, poi lo quota finché non è completamente definito. Usalo per bloccare in un solo passo uno schizzo disegnato a mano libera.", + "help.sketch.auto_constrain_from_datum": "Funziona come Vincola automaticamente, ma misura le posizioni da un punto di riferimento scelto invece che dall'origine. Usalo quando il pezzo è impostato a partire da un unico punto di riferimento, come un angolo o un foro.", + "help.sketch.blend_curve": "Unisce le estremità di due curve con una spline dolce tangente a entrambe, o anche continua in curvatura se lo scegli. Usalo per colmare un vuoto tra curve senza angoli vivi.", + "help.sketch.break": "Divide una curva in parti dove la attraversano altre curve, oppure nei punti che vi collochi. Usalo quando una parte di una curva ha bisogno di un proprio vincolo o deve essere eliminata da sola.", + "help.sketch.cancel": "Esce dallo schizzo e scarta tutto ciò che è stato modificato da quando è stato creato o aperto per la modifica. Le modifiche si recuperano con Annulla.", + "help.sketch.centerline": "Alterna le linee tra linee normali e linee d'asse, assi che i profili non includono. Usa una linea d'asse come asse per una rivoluzione, una specchiatura o un vincolo di simmetria.", + "help.sketch.chamfer.distance_angle": "Smussa l'angolo tra due linee, definito da una distanza lungo la prima linea e da un angolo. Usalo quando un disegno indica lo smusso come lunghezza e angolo, ad esempio 2 mm × 30°.", + "help.sketch.chamfer.equal_distance": "Smussa l'angolo tra due linee alla stessa distanza lungo entrambe. Usalo per il normale smusso a 45° su un contorno.", + "help.sketch.chamfer.two_distance": "Smussa l'angolo tra due linee, definito da una distanza separata lungo ciascuna linea. Usalo per smussi diversi da 45°, indicati con due lunghezze.", + "help.sketch.circle.center": "Disegna un cerchio a partire dal centro e dal diametro. Usalo per fori, risalti e altre lavorazioni tonde il cui centro è noto.", + "help.sketch.circle.three_point": "Disegna un cerchio passante per tre punti. Usalo quando il cerchio deve passare per punti dati, ad esempio di una geometria esistente.", + "help.sketch.circle.three_tangent": "Disegna un cerchio tangente a tre linee, cerchi o archi. Usalo per inserire un cerchio nello spazio tra tre curve, come il cerchio inscritto di un triangolo.", + "help.sketch.circle.two_point": "Disegna un cerchio a partire dai due estremi del suo diametro. Usalo quando sono noti i punti del bordo del cerchio invece del centro.", + "help.sketch.circle.two_tangent": "Disegna un cerchio di dimensione data tangente a due linee, cerchi o archi. Usalo per un arrotondamento a raggio fisso tra due curve.", + "help.sketch.conic": "Disegna una curva conica tra due punti finali, definita da un punto di vertice e da un valore rho. Usalo per transizioni dolci di cui vuoi regolare la pienezza, da piatta a quasi un angolo vivo.", + "help.sketch.constrainer": "Applica il vincolo che una o due curve selezionate hanno già quasi, come orizzontale, parallelo, tangente o uguale. Usalo per mettere in ordine uno schizzo disegnato a mano, un vincolo alla volta.", + "help.sketch.constraint.coincident": "Fa giacere un punto su un altro punto, una curva o un asse dello schizzo. Usalo per chiudere i vuoti in un profilo o per mettere un punto su una linea.", + "help.sketch.constraint.collinear": "Fa giacere due linee sulla stessa retta. Usalo per allineare bordi interrotti, come i due lati di un intaglio.", + "help.sketch.constraint.concentric": "Dà lo stesso centro a due cerchi o archi. Usalo per anelli, lamature e alberi con spallamenti.", + "help.sketch.constraint.curvature": "Fa proseguire con la stessa curvatura (G2) due curve che si incontrano in un punto finale. Usalo dove un raccordo tangente non è abbastanza dolce, ad esempio sulle superfici in vista.", + "help.sketch.constraint.equal": "Rende uguali in lunghezza le linee, o uguali in raggio cerchi e archi. Usalo perché una sola quota governi più elementi corrispondenti.", + "help.sketch.constraint.fix": "Fissa punti o curve dove si trovano, oppure li libera di nuovo. Usalo per la geometria che non deve muoversi quando trascini o risolvi lo schizzo.", + "help.sketch.constraint.horizontal_vertical": "Rende orizzontale o verticale, a seconda di quale è più vicino, una linea o la linea tra due punti. Usalo per squadrare un contorno rispetto agli assi dello schizzo.", + "help.sketch.constraint.midpoint": "Mette un punto a metà di una linea, oppure fa coincidere i punti medi di due linee. Usalo per centrare la geometria su un bordo.", + "help.sketch.constraint.parallel": "Rende parallele due linee. Usalo per i lati che devono restare paralleli quando cambiano altre quote.", + "help.sketch.constraint.perpendicular": "Fa incontrare due linee ad angolo retto. Usalo per angoli retti tra linee che non sono sugli assi dello schizzo.", + "help.sketch.constraint.polygon": "Rende poligono regolare una catena chiusa di linee: lati uguali, con ogni vertice su un unico cerchio. Usalo per rendere regolare un poligono disegnato a mano e mantenerlo tale.", + "help.sketch.constraint.symmetry": "Rende due punti o curve speculari tra loro rispetto a una linea o a un punto. Usalo per mantenere simmetrico un profilo quando modifichi un lato.", + "help.sketch.constraint.tangent": "Fa toccare tangenzialmente due curve, come una linea e un arco. Usalo per raccordi dolci senza angoli vivi.", + "help.sketch.construction": "Alterna le curve tra geometria normale e geometria di costruzione, che i profili non includono. Usa le curve di costruzione come ausilio per l'impostazione, i riferimenti e le quote.", "help.sketch.construction_line": "Crea una retta di costruzione infinita passante per due punti. Usala per impostare uno schizzo senza dividere i profili.", "help.sketch.construction_line.angle": "Crea una retta di costruzione infinita con un angolo rispetto all'asse X dello schizzo. Seleziona il punto e digita l'angolo, oppure scegli la direzione.", "help.sketch.construction_line.bisector": "Seleziona due linee rette dello schizzo che si intersecano, poi scegli una delle due bisettrici. Il risultato segue le linee di origine.", @@ -1412,11 +1570,71 @@ "help.sketch.construction_line.tangent_external": "Seleziona un cerchio o un arco di cerchio, poi un punto esterno e una soluzione di tangente. Le anteprime mostrano solo i contatti sull'arco disegnato.", "help.sketch.construction_line.tangent_two": "Seleziona due cerchi o archi di cerchio, poi scegli una tangente comune nell'anteprima. I contatti devono trovarsi sulle curve disegnate.", "help.sketch.construction_line.vertical": "Crea una retta di costruzione verticale infinita passante per un punto. Il suo vincolo verticale resta valido anche nelle modifiche successive.", + "help.sketch.copy": "Copia le curve e i punti dello schizzo selezionati, insieme ai vincoli e alle quote tra di essi. Incollali in questo schizzo o in un altro.", + "help.sketch.create": "Avvia un nuovo schizzo 2D su un piano o su una faccia piana del modello. Ogni estrusione, rivoluzione e sweep inizia con uno schizzo.", + "help.sketch.cut": "Copia la geometria dello schizzo selezionata e la elimina dallo schizzo, in un solo passo di annullamento. Incollala altrove in questo schizzo o in un altro.", + "help.sketch.dimension": "Aggiunge una quota di controllo: una lunghezza, una distanza, un angolo, un raggio o un diametro, a seconda di ciò che selezioni. Digita un valore o il nome di un parametro e lo schizzo lo segue.", + "help.sketch.edit": "Apre uno schizzo esistente per la modifica. Le lavorazioni costruite su di esso si aggiornano quando termini lo schizzo.", + "help.sketch.ellipse": "Disegna un'ellisse a partire dal centro, dall'estremità del suo asse maggiore e da un punto sul lato minore. Usalo per aperture ovali, camme e altre forme tonde allungate.", "help.sketch.equation_curve": "Disegna una curva da formule: x(t) e y(t), oppure y = f(x), su un intervallo. Le formule possono usare parametri e la curva li segue: usala per un'onda sinusoidale, una spirale o l'evolvente del dente di un ingranaggio.", + "help.sketch.extend": "Allunga una linea o un arco fino alla curva più vicina sul suo percorso, oppure estende due curve finché non si incontrano. Usalo per chiudere i vuoti in un profilo dopo un ritaglio o una modifica.", + "help.sketch.fillet": "Arrotonda con un arco di raggio dato l'angolo in cui si incontrano due linee o archi. Più angoli arrotondati insieme condividono lo stesso raggio.", + "help.sketch.finish": "Esce dallo schizzo e torna al modello. I suoi profili chiusi sono poi pronti per Estrusione, Rivoluzione e altre lavorazioni.", + "help.sketch.finish_auto_constrain": "Esegue Vincola automaticamente sullo schizzo, poi lo termina. Usalo per lasciare uno schizzo completamente definito in un solo passo.", + "help.sketch.fit_curves_to_section": "Taglia un corpo a mesh o solido con il piano dello schizzo e adatta linee e archi alla sezione. Usalo per ricostruire come schizzo modificabile un pezzo scansionato o importato.", + "help.sketch.include_3d": "Porta in questo schizzo spigoli e vertici del modello e curve di altri schizzi come riferimenti collegati, appiattendo su di esso quelli fuori dal suo piano. Usalo per costruire su una geometria che non giace sul piano dello schizzo.", + "help.sketch.infinite": "Alterna le linee tra linee normali e rette di costruzione infinite che attraversano tutta la vista. Usale come riferimenti di impostazione che non finiscono mai troppo presto.", + "help.sketch.insert_dxf": "Legge le curve di un disegno DXF in uno schizzo, scalate, ruotate e posizionate come imposti. Usalo per costruire a partire da un disegno 2D di un altro programma, come un contorno da tagliare al laser.", + "help.sketch.insert_svg": "Legge i tracciati di un disegno SVG in uno schizzo, scalati, ruotati e posizionati come imposti. Usalo per trasformare un logo o un contorno vettoriale in curve che puoi estrudere.", + "help.sketch.inspect": "Apre un rapporto sullo schizzo aperto: quanti gradi di libertà restano e se è completamente vincolato. Usalo per scoprire cosa si muove ancora prima di terminare lo schizzo.", + "help.sketch.intersect": "Aggiunge le curve o i punti in cui corpi, facce, spigoli o altri schizzi attraversano il piano dello schizzo. Restano collegati e seguono il modello quando cambia.", + "help.sketch.intersection_curve": "Crea curve 3D dove si incontrano due corpi o facce, collegate a essi. Usale come percorsi o guide che seguono il modello.", + "help.sketch.isoparametric_curve": "Crea una curva 3D lungo la direzione U o V di una faccia passante per un punto che scegli, collegata alla faccia. Usala come percorso o linea di divisione che segue la superficie.", + "help.sketch.line": "Disegna linee collegate punto per punto, come catena aperta o contorno chiuso. È lo strumento di base per la maggior parte dei profili.", + "help.sketch.line.midpoint": "Disegna una linea dal suo punto medio fino a un'estremità, con l'altra estremità speculare. Usalo per linee centrate su un punto, come un bordo simmetrico.", + "help.sketch.line3d": "Disegna linee rette collegate tra punti nello spazio, con Schizzo 3D attivo. Usalo per percorsi e telai che escono dal piano dello schizzo.", + "help.sketch.mirror": "Specchia curve e punti dello schizzo rispetto a una linea o a un asse e aggiunge vincoli di simmetria. Usalo per disegnare metà di un profilo simmetrico e completarlo.", + "help.sketch.move": "Sposta o ruota curve e punti dello schizzo, oppure una loro copia. Usalo per riposizionare la geometria mantenendone forma e vincoli.", + "help.sketch.offset": "Crea una copia di una catena di curve a una distanza stabilita, su un lato o su entrambi. Usalo per pareti, contorni e giochi; la distanza resta una quota modificabile.", + "help.sketch.paste": "Incolla nello schizzo attivo la geometria dello schizzo che hai copiato o tagliato. Posizionala in un punto o spostala di uno scostamento.", + "help.sketch.pattern.circular": "Copia la geometria dello schizzo attorno a un punto centrale, su un cerchio completo o su un angolo stabilito. Usalo per cerchi di bulloni, raggi e denti.", + "help.sketch.pattern.rectangular": "Copia la geometria dello schizzo in righe e colonne con spaziature stabilite. Usalo per griglie di fori, asole o prese d'aria.", + "help.sketch.point": "Colloca un punto dello schizzo. Usalo come riferimento per quote, vincoli, centri di fori o lavorazioni successive.", + "help.sketch.point3d": "Colloca un punto nello spazio, con Schizzo 3D attivo. Usalo come riferimento per curve e percorsi 3D.", + "help.sketch.polygon.circumscribed": "Disegna un poligono regolare attorno a un cerchio, dimensionato dal centro al punto medio di un lato. Usalo quando è nota l'apertura chiave, come per un dado esagonale.", + "help.sketch.polygon.edge": "Disegna un poligono regolare a partire dai due estremi di un lato. Usalo quando sono note la lunghezza e la posizione di un lato.", + "help.sketch.polygon.inscribed": "Disegna un poligono regolare dentro un cerchio, dimensionato dal centro a un vertice. Usalo quando è nota la dimensione tra i vertici opposti.", + "help.sketch.project": "Porta in questo schizzo spigoli, facce e vertici del modello e curve di altri schizzi, proiettati sul suo piano. Restano collegati e seguono il modello quando cambia.", + "help.sketch.project_to_surface": "Proietta le curve di altri schizzi lungo la normale di questo schizzo su una faccia, come curve 3D collegate. Usalo per applicare un logo, una linea di divisione o un percorso su una superficie curva.", + "help.sketch.rectangle.center": "Disegna un rettangolo a partire dal centro e da un angolo. Usalo per contorni centrati su un punto, come l'origine.", + "help.sketch.rectangle.rounded": "Disegna un rettangolo con tutti e quattro gli angoli arrotondati con lo stesso raggio. Usalo per piastre, coperchi e aperture con angoli arrotondati.", + "help.sketch.rectangle.three_point": "Disegna un rettangolo a partire da un lato e da un punto sul lato opposto. Usalo per rettangoli inclinati rispetto agli assi dello schizzo.", + "help.sketch.rectangle.two_point": "Disegna un rettangolo a partire da due angoli opposti. Usalo per i contorni di piastre, blocchi e tasche.", + "help.sketch.scale": "Scala curve e punti dello schizzo di un fattore rispetto a un punto base. Usalo per ridimensionare una parte di uno schizzo senza ridisegnarla.", + "help.sketch.slot.arc_center": "Disegna un'asola curva di larghezza data lungo un arco definito da centro, inizio e fine. Usalo per asole di regolazione attorno a un perno.", + "help.sketch.slot.arc_three_point": "Disegna un'asola curva di larghezza data lungo un arco passante per tre punti. Usalo quando il percorso dell'asola deve passare per punti dati.", + "help.sketch.slot.center_point": "Disegna un'asola retta di larghezza data dal suo centro al centro di un arco di estremità. Usalo per asole centrate su un punto.", + "help.sketch.slot.center_to_center": "Disegna un'asola retta di larghezza data tra i centri dei suoi due archi di estremità. Usalo per asole quotate per interasse, come le asole di regolazione per viti.", + "help.sketch.slot.overall": "Disegna un'asola retta di larghezza data da un'estremità esterna all'altra. Usalo quando conta la lunghezza complessiva dell'asola.", + "help.sketch.spline.control_point": "Disegna una spline cubica modellata da punti di controllo per i quali non passa. Usalo per curve dolci e fluide che modelli spostando i punti.", + "help.sketch.spline.control_point_5": "Disegna una spline di grado 5 modellata da punti di controllo. Usalo dove le curve devono raccordarsi con una curvatura più dolce, ad esempio su superfici di design in vista.", + "help.sketch.spline.fit_point": "Disegna una spline dolce che passa per ogni punto che collochi, aperta o chiusa. Usalo per tracciare una forma attraverso punti noti.", + "help.sketch.spline3d": "Disegna una curva dolce nello spazio passante per dei punti, con Schizzo 3D attivo. Usalo per percorsi di sweep e tracciati di cavi che escono dal piano dello schizzo.", + "help.sketch.spun_profile": "Crea nel piano dello schizzo il contorno descritto da un corpo quando ruota attorno a un asse. Usalo per ottenere la silhouette di un pezzo tornito per una rivoluzione o un disegno.", + "help.sketch.text": "Scrive del testo nello schizzo con il carattere, l'altezza e l'angolo scelti; le sue parole possono seguire i parametri. Estrudilo o mettilo in rilievo per etichette e scritte.", + "help.sketch.transform": "Scala, ruota e sposta la geometria dello schizzo rispetto a un punto centrale in un solo passo. Usalo per riposizionare e ridimensionare una selezione in una volta.", + "help.sketch.trim": "Rimuove il tratto di una curva compreso tra gli incroci più vicini attorno al punto in cui fai clic. Usalo per ripulire linee sovrapposte e ottenere un profilo chiuso.", "help.solid.align": "Sposta i corpi selezionati in modo che un punto, una faccia o un piano su di essi coincida con un punto, una faccia o un piano altrove. Usalo per posizionare un pezzo contro un altro senza misurare lo scostamento.", + "help.solid.boundary_fill": "Crea solidi dalle regioni racchiuse da corpi utensile sovrapposti, riempiendo solo le regioni che scegli. Usalo per costruire una forma dallo spazio delimitato insieme da più corpi.", + "help.solid.bounding_solid": "Crea un parallelepipedo che racchiude i corpi selezionati, con un margine facoltativo. Usalo per vedere l'ingombro complessivo di un pezzo, o come grezzo da cui viene lavorato.", + "help.solid.box": "Crea un parallelepipedo da lunghezza, larghezza e altezza, a partire da un angolo o centrato su un punto. Usalo per un blocco veloce, un segnaposto o un corpo in cui ricavare lavorazioni.", "help.solid.chamfer": "Taglia gli spigoli selezionati con uno smusso piano della distanza impostata. Usalo per eliminare gli spigoli vivi o per facilitare l'ingresso di un perno o di una vite.", + "help.solid.coil": "Crea un corpo elicoidale, come una molla, da un diametro, dal numero di spire, dall'altezza o dal passo e da una sezione tonda o quadrata. Usalo per molle, eliche e creste elicoidali.", "help.solid.combine": "Unisce i corpi, li sottrae da un corpo di destinazione oppure tiene solo il volume che hanno in comune, e può conservare i corpi utensile. Usalo per fondere parti, per tagliare una forma con un'altra o per trovare dove due corpi si sovrappongono.", + "help.solid.cone": "Crea un cono da un cerchio di base e un'altezza, oppure un tronco di cono se anche la sommità ha un raggio. Usalo per rastremazioni, imbuti, svasature e punte.", + "help.solid.cylinder": "Crea un cilindro da un raggio o un diametro e un'altezza, poggiato su un punto lungo un asse. Usalo per alberi, perni, risalti e grezzi tondi.", "help.solid.draft": "Dà uno sformo alle facce selezionate con l'angolo impostato, misurato da un piano neutro. Usalo su pezzi stampati o fusi perché escano dallo stampo.", + "help.solid.emboss": "Solleva da una faccia i profili dello schizzo, come un testo o un logo, oppure li incide al suo interno. Usalo per scritte, etichette e marcature su facce piane o tonde.", "help.solid.extrude": "Spinge il profilo di uno schizzo nel 3D lungo una distanza, aggiungendo o togliendo materiale. Usalo per trasformare un contorno piatto in un blocco, una sporgenza o una tasca.", "help.solid.fillet": "Arrotonda gli spigoli selezionati con un raccordo circolare liscio del raggio impostato. Usalo per addolcire gli spigoli vivi per resistenza, comodità o estetica.", "help.solid.hole": "Crea un foro tondo del diametro impostato in un punto scelto di una faccia. Usalo per i passaggi delle viti, i perni e altre semplici aperture tonde.", @@ -1425,20 +1643,60 @@ "help.solid.move": "Sposta o ruota i corpi selezionati, o una loro copia, della distanza e dell'angolo impostati. Usalo per riposizionare un corpo o per crearne un secondo in un altro punto.", "help.solid.offset_face": "Spinge le facce selezionate verso l'interno o l'esterno della distanza impostata e le facce intorno le seguono. Usalo per ispessire una parete, approfondire una tasca o alzare una sporgenza senza modificare lo schizzo.", "help.solid.pattern.circular": "Ripete gli oggetti selezionati intorno a un asse, per il numero e l'angolo totale impostati. Usalo per cerchi di bulloni, raggi, alette e denti.", + "help.solid.pattern.path": "Copia lavorazioni o corpi lungo un percorso dello schizzo, per numero e spaziatura. Usalo per fori lungo uno spigolo curvo o parti lungo una guida.", "help.solid.pattern.rectangular": "Ripete gli oggetti selezionati in una o due direzioni con il numero e la spaziatura impostati. Usalo per file e griglie di fori, nervature, perni o piedini.", + "help.solid.pipe": "Crea un tubo tondo lungo un percorso di curve dello schizzo, pieno o cavo con uno spessore di parete. Usalo per tubi, fili, cavi e guide.", "help.solid.press_pull": "Aggiunge o toglie materiale mentre trascini una faccia, oppure arrotonda gli spigoli mentre li trascini. Usalo per modifiche rapide direttamente sul modello.", "help.solid.remove": "Toglie i corpi selezionati dalla progettazione da questo punto della cronologia in poi; i passaggi precedenti li mantengono. Usalo per scartare un corpo ausiliario, come un utensile da taglio che non serve più.", "help.solid.replace_face": "Porta le facce piane selezionate su un piano o su una faccia parallela, e il corpo cresce o si riduce per raggiungerlo. Usalo per portare una faccia a filo con un altro pezzo o con un piano di costruzione.", "help.solid.revolve": "Fa ruotare il profilo di uno schizzo intorno a un asse per creare un solido rotondo. Usalo per alberi, manopole, ciotole e qualsiasi pezzo tondo attorno a una linea d'asse.", + "help.solid.rib": "Aggiunge una parete sottile lungo una curva aperta dello schizzo, estesa fino alle facce successive del corpo. Usalo per irrigidire pareti e angoli, ad esempio in pezzi stampati.", "help.solid.scale": "Ingrandisce o rimpicciolisce i corpi selezionati di un fattore, uguale in ogni direzione oppure uno per asse. Usalo per ridimensionare una forma importata o per compensare il ritiro.", "help.solid.shell": "Svuota un solido lasciando pareti dello spessore impostato e può aprire le facce che scegli. Usalo per scatole, coperchi e altri pezzi a parete sottile.", + "help.solid.sphere": "Crea una sfera da un raggio o un diametro attorno a un punto centrale. Usalo per palline, manopole ed estremità arrotondate.", "help.solid.split_body": "Taglia un corpo in corpi separati con un piano, la faccia di un altro corpo o una linea dello schizzo. Usalo per ottenere pezzi che vengono stampati, lavorati o colorati separatamente.", "help.solid.split_face": "Divide le facce selezionate lungo un piano, la faccia di un altro corpo o delle curve di schizzo, senza tagliare il corpo. Usalo per dare a una parte di una faccia il proprio sformo, colore o offset.", "help.solid.sweep": "Trascina il profilo di uno schizzo lungo un percorso per creare un solido. Usalo per tubi, guide, maniglie e qualsiasi pezzo a sezione costante che segue una curva.", + "help.solid.thread": "Aggiunge una filettatura metrica ISO a una faccia cilindrica, come filettatura cosmetica o ricavata nel corpo. Usalo per bulloni, fori filettati e parti a vite stampate.", + "help.solid.torus": "Crea un anello da un raggio maggiore attorno al suo asse e un raggio minore per la sua sezione tonda. Usalo per O-ring, maniglie e perline tonde.", + "help.solid.web": "Aggiunge una parete sottile lungo ogni curva aperta dello schizzo, ciascuna estesa fino alle facce successive del corpo. Usalo per un reticolo di pareti di sostegno all'interno di involucri e vassoi.", + "help.surface.blend": "Crea una superficie che collega gli spigoli di due corpi, tangente o continua in curvatura a entrambi. Usalo per unire dolcemente due superfici attraverso un vuoto.", + "help.surface.boundary": "Crea una superficie che riempie un contorno chiuso di due-quattro curve dello schizzo, o una rete di curve. Usalo per pannelli a forma libera definiti dai loro bordi.", + "help.surface.extend": "Allunga una superficie oltre i suoi bordi, di una distanza o fino a un piano o a una faccia. Usalo per far sovrapporre le superfici prima di ritagliarle o cucirle.", + "help.surface.extrude": "Spinge curve o profili dello schizzo lungo una distanza fino a formare una superficie, solo pareti, senza chiusure. Usalo per pareti sottili e per utensili di taglio o di divisione.", + "help.surface.fair": "Regolarizza l'interno delle facce a forma libera mentre i loro bordi restano fermi. Usalo per eliminare gobbe e riflessi ondulati in una superficie.", + "help.surface.loft": "Crea una superficie che passa in ordine per due o più sezioni, senza chiusure. Usalo per gusci e transizioni tra forme diverse.", + "help.surface.match": "Ricostruisce il bordo di una superficie in modo che incontri il bordo di un altro corpo, con tangenza o curvatura corrispondenti. Usalo per chiudere dolcemente una giunzione tra due superfici.", + "help.surface.offset": "Crea una copia delle facce a una distanza lungo le loro normali, come nuovo corpo di superficie. Usalo per rivestimenti, giochi o l'altro lato di una parete.", + "help.surface.patch": "Riempie una regione dello schizzo o un anello chiuso di spigoli con una faccia, piana o dolcemente curva. Usalo per chiudere i fori in un corpo di superficie.", + "help.surface.reverse_normal": "Rovescia le facce di un corpo di superficie, in modo che il loro lato esterno sia rivolto dall'altra parte. Usalo prima di ispessire o cucire quando le facce sono rivolte verso l'interno.", + "help.surface.revolve": "Fa ruotare curve o profili dello schizzo attorno a un asse per ottenere una superficie, senza chiusure. Usalo per gusci rotondi come paralumi, ciotole e ugelli.", + "help.surface.ruled": "Crea una striscia di rette a partire da una catena di spigoli, lungo la normale o la tangente della faccia oppure ad angolo. Usalo per pareti con sformo, prolungamenti e superfici di divisione.", + "help.surface.stitch": "Cuce insieme i corpi di superficie lungo i loro spigoli condivisi, formando un solido quando si chiudono. Usalo per trasformare un insieme di superfici in un unico corpo.", + "help.surface.sweep": "Muove curve o profili dello schizzo lungo un percorso per ottenere una superficie, senza chiusure. Usalo per grondaie, rifiniture e altre forme sottili che seguono un percorso.", + "help.surface.thicken": "Dà uno spessore a un corpo di superficie, trasformandolo in un solido. Usalo per ottenere da superfici pezzi stampabili o lavorabili.", + "help.surface.trim": "Taglia una superficie con un piano, un'altra superficie o curve dello schizzo, e mantiene il lato che scegli. Usalo per dare forma alle superfici prima di cucirle.", + "help.surface.unstitch": "Separa le facce di un corpo in corpi di superficie indipendenti; un solido si scompone in superfici. Usalo per rielaborare singole facce di un corpo.", + "help.surface.untrim": "Rimuove i fori da una faccia, oppure riporta il suo contorno esterno ai limiti naturali della superficie. Usalo per recuperare la superficie intera dopo un ritaglio.", "help.third_party": "Licenze di terze parti", "help.timeline.compute_all": "Ricostruisce ogni lavorazione della cronologia dall'inizio. Usalo per aggiornare il modello e vedere quali lavorazioni falliscono.", "help.timeline.delete": "Elimina le lavorazioni selezionate dalla cronologia; eliminando uno schizzo si eliminano anche le lavorazioni costruite su di esso. Usalo per togliere un passaggio che non serve più.", + "help.timeline.dependents": "Elenca le lavorazioni che verrebbero eliminate insieme a una lavorazione, e quelle che fallirebbero senza di essa. Usalo prima di eliminare o modificare una lavorazione.", "help.timeline.play": "Riproduce le lavorazioni rimanenti una alla volta, con una pausa di mezzo secondo dopo ogni ricostruzione. Esegui di nuovo questo comando o premi Escape per arrestare; Vai alla fine salta subito alla fine.", + "help.view.apply": "Ruota la vista verso una direzione standard come frontale, dall'alto o isometrica, oppure verso una vista con nome salvata. Usalo per guardare il modello da un'angolazione fissa.", + "help.view.delete": "Elimina una vista con nome salvata. Il modello non viene modificato.", + "help.view.fit": "Esegue zoom e panoramica in modo che l'intero modello, o la selezione, riempia la vista. Usalo quando il modello è uscito dall'inquadratura.", + "help.view.grid": "Mostra o nasconde la griglia nella vista. Usala come guida per dimensioni e orientamento durante la modellazione.", + "help.view.ground_shadow": "Mostra o nasconde l'ombra del modello sul suolo. Usala per valutare più facilmente altezza e posizione.", + "help.view.home": "Riporta la vista alla direzione iniziale e inquadra tutto il modello. Usalo per tornare rapidamente a una vista nota.", + "help.view.list": "Elenca le viste con nome salvate nel progetto. Usalo per trovare una vista salvata in precedenza.", + "help.view.navigate": "Ruota, sposta o ingrandisce la vista a passi, come un quarto di giro a sinistra. Usalo per muovere la camera con precisione invece di trascinare.", + "help.view.navigation_bar": "Mostra o nasconde la barra di navigazione in basso nella vista. Nascondila per avere più spazio, mostrala per raggiungere con il mouse gli strumenti della vista.", + "help.view.rename": "Dà un nuovo nome a una vista con nome salvata. La vista in sé resta la stessa.", + "help.view.save": "Salva la posizione attuale della camera come vista con nome nel progetto. Usalo per tornare in seguito alla stessa angolazione, ad esempio per le schermate.", + "help.view.spin": "Fa orbitare la vista attorno al modello in modo continuo fino al clic o al tasto successivo. Usalo per presentare un modello da tutti i lati.", + "help.view.tool": "Mantiene attivo uno strumento della camera (orbita, panoramica o zoom), così trascinando con il pulsante sinistro del mouse si muove la vista finché non lo disattivi. Usalo su un trackpad o senza pulsante centrale del mouse.", + "help.view.visual_style": "Cambia il modo in cui il modello viene disegnato: ombreggiato con o senza spigoli, reticolo, oppure con gli spigoli nascosti. Usa il reticolo per vedere all'interno, l'ombreggiato per un aspetto pulito.", "history.sketch_under_constrained": "Sottovincolato ({count} gradi di libertà)", "history.sketch_under_constrained_one": "Sottovincolato ({count} grado di libertà)", "hole_presets.confirm_delete": "Conferma eliminazione", @@ -1615,6 +1873,10 @@ "material.selection.mixed": "Materiali diversi", "measure.at": "In", "measure.item": "Elemento {number}", + "measure.quick_copied": "Misura copiata", + "measure.quick_copy": "Fai clic per copiare la misura", + "measure.quick_edges": "{count} spigoli", + "measure.quick_faces": "{count} facce", "messages.ago_h": "{n} h fa", "messages.ago_min": "{n} min fa", "messages.ago_s": "{n} s fa", @@ -1767,6 +2029,11 @@ "not a move": "non è uno spostamento", "not a number": "non è un numero", "open a sketch first": "apri prima uno schizzo", + "parameters.apply_live": "Applica ogni modifica quando esci dal campo", + "parameters.apply_live_tip": "Disattivato: le modifiche restano in attesa fino ad Applica o OK, poi il progetto si ricostruisce una sola volta per tutte. Annulla o Escape le scarta.", + "parameters.held": "{count} modifiche in attesa di Applica", + "parameters.held_hint": "Modifica espressioni e unità. Le modifiche restano in attesa di Applica o OK.", + "parameters.held_one": "1 modifica in attesa di Applica", "parts.automatic_name": "automatico", "parts.dimension.across_flats": "Apertura chiave", "parts.dimension.bore": "Foro", @@ -1957,6 +2224,8 @@ "prefs.origin_plane_names": "Nomi dei piani di origine", "prefs.origin_plane_names_note": "Nomi mostrati nella Struttura, nelle finestre di dialogo e negli elenchi. {top} è XY, {front} è XZ e {right} è YZ, con Z verso l'alto. Comandi, script e progetti salvati mantengono XY, XZ e YZ.", "prefs.overall_speed": "Velocità complessiva", + "prefs.parameter_list_rows": "Righe dell'elenco dei parametri", + "prefs.parameter_list_rows_tip": "La freccia giù in un campo valore vuoto elenca i parametri, prima i preferiti, fino a questo numero. Un numero minore mantiene leggero l'elenco in un progetto grande.", "prefs.persistent_ribbon_pressed": "Evidenzia i comandi attivi della barra multifunzione", "prefs.persistent_ribbon_pressed_help": "Evidenzia i comandi della barra multifunzione finché i loro effetti sono attivi. Disattiva per mantenere l'aspetto neutro precedente e la guida dei comandi.", "prefs.perspective_fov": "Campo visivo", @@ -1986,6 +2255,8 @@ "prefs.quality_fastest": "più veloce", "prefs.quality_slowest": "più lento", "prefs.quality_smoother": "più fluido", + "prefs.quick_measure": "Misura rapida nell'angolo della vista", + "prefs.quick_measure_note": "La dimensione della selezione nell'angolo in basso a destra della vista: la lunghezza di uno spigolo, oppure Ø e R per uno tondo, l'area di una faccia, la posizione di un punto, la distanza e l'angolo tra due elementi, il totale di più spigoli o facce. Un clic la copia.", "prefs.render_previous_geometry_budget": "Usa il budget di geometria precedente per l'anteprima di Rendering", "prefs.render_previous_geometry_budget_help": "Ripristina il limite fisso di geometria precedente (512 MiB su desktop a 64 bit, 128 MiB altrove) invece del budget basato sul dispositivo.", "prefs.reports_saved": "{n} salvati", @@ -2031,6 +2302,8 @@ "prefs.sketch_tab_always_note": "La scheda Schizzo resta nella barra multifunzione, subito dopo Modella, finché nessuno schizzo è aperto. I suoi strumenti di disegno avviano allora uno schizzo sul piano o sulla faccia selezionati, oppure sul piano che selezioni subito dopo, con lo strumento già attivo.", "prefs.sketch_tracking_guides": "Guide di allineamento di centro e punto medio", "prefs.sketch_tracking_guides_note": "Passa il puntatore sul centro di un cerchio o sul punto medio di un segmento, poi spostati in orizzontale o in verticale per mostrare una guida punteggiata temporanea.", + "prefs.sketch_tracking_points": "Guide di allineamento da estremità e angoli durante il disegno", + "prefs.sketch_tracking_points_note": "Mentre uno strumento di disegno attende un punto, passando il puntatore sull'estremità di una linea o di un arco, sull'angolo di un rettangolo o su un punto libero compare la stessa guida punteggiata temporanea di un centro o di un punto medio. Disattivato: solo centri e punti medi.", "prefs.sketching": "Schizzi", "prefs.snap_drawing_note": "Mentre disegni o trascini un punto dello schizzo.", "prefs.source": "Fonte", @@ -2058,6 +2331,16 @@ "prefs.theme_follow_off": "Disattivato", "prefs.theme_follow_stop": "Smetti di seguire", "prefs.theme_import_hint": "Fai clic per scegliere un file di tema, trascinane uno qui oppure premi Ctrl+V (Cmd+V su Mac) con un tema copiato", + "prefs.theme_option_opacity": "Opacità", + "prefs.theme_option_opacity_help": "Regola l'opacità dei pannelli flottanti. Una soglia minima di leggibilità protegge il testo sopra i colori della vista.", + "prefs.theme_option_tab_colours": "Colori delle schede", + "prefs.theme_option_tab_colours_help": "Usa i colori dei pannelli e della vista di questo tema per ogni scheda della barra multifunzione.", + "prefs.theme_option_tab_fade_ms": "Transizione tra schede", + "prefs.theme_option_tab_fade_ms_help": "Sfuma i colori dei pannelli e della vista quando si cambia scheda. Zero cambia all'istante.", + "prefs.theme_options_badge": "Opzioni", + "prefs.theme_options_more": "Altro · {count}", + "prefs.theme_options_reset": "Reimposta", + "prefs.theme_options_unavailable": "Non disponibile su questo dispositivo", "prefs.theme_paste": "Incolla tema", "prefs.theme_paste_none": "Negli appunti non c'è nessun tema SolidCraft", "prefs.timeline_half_hour": "−30 min", @@ -2079,6 +2362,8 @@ "prefs.under_cursor": "Sotto il cursore", "prefs.units_precision": "Unità e precisione", "prefs.updates": "Aggiornamenti", + "prefs.value_boxes_in_view": "Mostra i valori accanto alle maniglie nella vista", + "prefs.value_boxes_in_view_tip": "Il valore di un comando (Distanza, Raggio…) in un riquadro accanto alla sua maniglia, sopra il modello. Disattivato: lo digiti nella finestra del comando, che riceve la tastiera dopo una selezione, e niente copre il modello.", "prefs.view": "Comportamento della vista", "prefs.view_confirm_corner": "OK e Annulla nell'angolo della vista", "prefs.view_confirm_corner_note": "Mentre è aperta una finestra di dialogo o uno schizzo, una spunta e una croce in alto a destra nella vista fanno ciò che fanno OK e Annulla della finestra, oppure terminano e annullano lo schizzo. Il navigatore della vista scende sotto di esse.", @@ -2088,6 +2373,12 @@ "prefs.viewport_background": "Sfondo", "prefs.viewport_background_colour": "Colore", "prefs.viewport_background_theme": "Sfumatura del tema", + "prefs.viewport_section_status": "Mostra lo stato di sezione della vista", + "prefs.viewport_section_status_help": "Mostra l'impostazione della sezione nella vista. Escape e il pulsante attivo della barra multifunzione la disattivano. Disattiva per mantenere i controlli di sezione precedenti.", + "prefs.viewport_status_details": "Dettagli della vista in tempo reale", + "prefs.viewport_status_details_help": "Modifica le impostazioni di analisi e sezione attive nelle schede di dettaglio standard. Disattiva per ripristinare le legende di analisi in sola lettura e la finestra di dialogo Sezione.", + "prefs.viewport_status_summary": "Raggruppa gli elementi di stato della vista", + "prefs.viewport_status_summary_help": "Raggruppa tre o più elementi di stato della vista in un unico indicatore di riepilogo. Disattiva per mantenere ogni elemento nella riga completa.", "prefs.wheel": "Rotellina", "prefs.wheel_trackpad": "Rotellina e trackpad", "prefs.workspace": "Area di lavoro", @@ -2966,7 +3257,9 @@ "render.output_ready": "Pronto per il rendering", "render.output_run_check": "Esegui verifica", "render.output_size_limit": "{method}: fino a {dimension} px per lato e {pixels} pixel. Dimensioni massime consentite: {width} × {height}.", + "render.output_size_method_profile": "Per renderizzare {width} × {height}, scegli {method} con il profilo di risorse della CPU {profile}, oppure una dimensione minore.", "render.output_size_ok": "Dimensioni OK · {width} × {height} px", + "render.output_size_profile": "Per renderizzare {width} × {height}, scegli il profilo di risorse della CPU {profile}, oppure una dimensione minore.", "render.output_swap_orientation": "Scambia larghezza e altezza", "render.output_tools_inspector": "Modifica dimensioni e formato nell'ispettore Uscita.", "render.output_view_image": "Visualizza immagine", @@ -3179,6 +3472,7 @@ "render.size_custom": "Personalizzato…", "render.size_full_hd": "Full HD", "render.size_hd": "HD", + "render.size_needs_profile": "{size} · richiede {profile}", "render.size_presets": "Preimpostazioni delle dimensioni", "render.size_presets_pending": "In arrivo con l'editor Uscita", "render.size_preview": "Anteprima", @@ -3466,6 +3760,24 @@ "sheet.flange_type_contour": "Contorno", "sheet.flange_type_edge": "Spigolo", "sheet.no_body": "non c'è nessun corpo in lamiera: creane uno con Flangia > Base, oppure trasforma una piastra in lamiera con Converti in lamiera (scegli la sua faccia piana più grande)", + "sheet.personal_rules.confirm_delete": "Conferma eliminazione", + "sheet.personal_rules.delete": "Elimina", + "sheet.personal_rules.delete_prompt": "Eliminare questa regola personale? Le regole già presenti nei progetti non cambiano.", + "sheet.personal_rules.full": "La libreria personale contiene già 64 regole.", + "sheet.personal_rules.invalid_name": "Inserisci un nome breve senza interruzioni di riga né caratteri di controllo.", + "sheet.personal_rules.invalid_values": "Completa i valori della regola: ciascuno deve risultare valido in questo progetto, con uno spessore positivo e un fattore K da 0 a 1.", + "sheet.personal_rules.missing": "La regola personale selezionata non è più disponibile.", + "sheet.personal_rules.name": "Nome della regola", + "sheet.personal_rules.name_exists": "Esiste già una regola personale con questo nome. Usa un nome diverso.", + "sheet.personal_rules.name_hint": "Un nome univoco", + "sheet.personal_rules.overwrite": "Salva le modifiche nella regola personale selezionata", + "sheet.personal_rules.rename": "Rinomina", + "sheet.personal_rules.rule": "Regola personale", + "sheet.personal_rules.save": "Salva come nuova", + "sheet.personal_rules.scope": "Salvata nel tuo profilo per tutti i progetti.", + "sheet.personal_rules.select": "Seleziona una regola", + "sheet.personal_rules.title": "Regole personali", + "sheet.personal_rules.unsupported": "Questa regola ha valori che questa versione non può usare.", "shortcut.json_error": "i parametri devono essere in formato JSON: {error}", "shortcut.no_command": "nessun comando `{id}`; il testo prima del JSON deve essere un ID comando", "shortcut.pin": "Fissa in cima al riquadro S", @@ -3502,6 +3814,7 @@ "sketch_text.align_right": "Destra", "sketch_text.bold": "Grassetto", "sketch_text.builtin_font": "{family} (integrato)", + "sketch_text.formula": "Inizia con = per costruire le parole dai parametri: ='W' + number(Width)", "sketch_text.italic": "Corsivo", "sketch_text.loading_fonts": "Ricerca dei caratteri installati…", "sketch_text.on_path": "Il testo segue il percorso: la curva ne determina la posizione", @@ -3642,6 +3955,8 @@ "ui.3d_mouse_section": "Mouse 3D", "ui.3d_mouse_show_in_view": "Mostra il mouse 3D nella vista 3D", "ui.3d_mouse_show_in_view_tip": "Un'immagine del tuo mouse 3D nell'angolo in basso a sinistra, che si muove quando lo muovi.", + "ui.3d_mouse_sketch_orbit": "Ruota gli schizzi con il mouse 3D", + "ui.3d_mouse_sketch_orbit_tip": "Il mouse 3D ruota la vista anche mentre uno schizzo è aperto e visto frontalmente, come può fare il mouse. Disattivato: sposta e ingrandisce lo schizzo finché non inclini la vista.", "ui.3d_mouse_skip": "Il mio dispositivo non può farlo", "ui.3d_mouse_skip_tip": "Salta questo movimento in entrambi i versi; la calibrazione lo esclude.", "ui.3d_mouse_speed": "Velocità del mouse 3D", @@ -3710,6 +4025,7 @@ "ui.agent_chat_auth_terminal": "Esegui questo comando in un terminale, poi premi di nuovo Connetti:", "ui.agent_chat_auth_title": "Accedi all'agente", "ui.agent_chat_cancel": "Annulla", + "ui.agent_chat_cannot_start": "Installato, ma non si avvia: {error}", "ui.agent_chat_changes": "Modifiche: {n}, un solo passo di annullamento", "ui.agent_chat_changes_n": "{n} modifiche", "ui.agent_chat_changes_of": "{n}/{limit} modifiche", @@ -3802,9 +4118,12 @@ "ui.agent_chat_queued_stopped": "In coda (arrestato, non inviato)", "ui.agent_chat_refresh": "Aggiorna", "ui.agent_chat_refused": "Non consentito all'agente: {reason}", + "ui.agent_chat_reinstall": "{agent} è installato ({program}), ma non si è avviato quando SolidCraft lo ha controllato: {error}\nLa sua installazione potrebbe essere incompleta o creata per un altro tipo di computer. Installalo di nuovo con `{install}`, verifica in un terminale che stampi la sua versione, poi premi Aggiorna.", "ui.agent_chat_remove": "Rimuovi dalla coda", "ui.agent_chat_remove_file": "Rimuovi da questo messaggio", "ui.agent_chat_remove_imported": "Rimuovi da questo messaggio; la parte resta nel progetto (Annulla la rimuove)", + "ui.agent_chat_reply_cut_off": "La risposta ha raggiunto il limite di lunghezza prima di terminare, quindi potrebbe essere incompleta. Chiedi all'agente di proseguire, oppure di dare una risposta più breve.", + "ui.agent_chat_reply_declined": "Il modello ha rifiutato di rispondere a questo messaggio.", "ui.agent_chat_retry": "Riprova", "ui.agent_chat_right": "Destra", "ui.agent_chat_save": "Salva", @@ -3863,12 +4182,17 @@ "ui.ai_copy_suffix": "copia", "ui.ai_custom_url": "URL personalizzato", "ui.ai_default": "Predefinito", + "ui.ai_edit": "Modifica", + "ui.ai_edit_agent_title": "Modifica agente", + "ui.ai_edit_gone": "Questo agente è stato rimosso, quindi non c'è nulla da salvare.", + "ui.ai_edit_tip": "Cambia il nome, il comando o l'endpoint di questo agente. Una chiave salvata resta nascosta.", "ui.ai_http_busy": "l'endpoint è occupato o ha superato il limite; riprova più tardi", "ui.ai_http_key": "l'endpoint ha rifiutato la chiave: controllala in Preferenze > IA", "ui.ai_http_not_found": "endpoint o modello inesistente: controlla l'URL di base e il nome del modello", "ui.ai_http_too_large": "la conversazione è troppo grande per questo endpoint; inizia una nuova chat", "ui.ai_key": "Chiave API", "ui.ai_key_hint": "facoltativa; conservata nel portachiavi di sistema", + "ui.ai_key_keep_hint": "lascia vuoto per mantenere la chiave salvata", "ui.ai_key_needs_https": "La chiave viene inviata solo tramite HTTPS o a questo computer.", "ui.ai_key_note": "Memorizzata nel portachiavi di sistema, mai nelle impostazioni di SolidCraft. Lascia vuoto per un server locale.", "ui.ai_keychain_error": "Il portachiavi di sistema ha rifiutato l'operazione: {error}", @@ -3900,6 +4224,7 @@ "ui.ai_request_timeout": "Timeout della richiesta", "ui.ai_request_timeout_hint": "Quanto può durare una richiesta al modello verso questo endpoint, da 30 s a un'ora. Aumentalo per un modello locale lento.", "ui.ai_reset": "Reimposta", + "ui.ai_save": "Salva", "ui.ai_settings": "Impostazioni IA…", "ui.ai_show_features": "Mostra funzioni IA", "ui.ai_show_features_note": "La chat con l'agente: un pannello in cui un agente di programmazione che già usi o un endpoint di modello a tua scelta opera sul progetto aperto. Se disattivata: nessun pulsante IA, scorciatoia, voce di menu o pannello, e nessuna ricerca o avvio.", @@ -4073,6 +4398,40 @@ "ui.design_properties.project": "Progetto", "ui.design_properties.revision": "Revisione", "ui.design_saved": "Salvato in {path}", + "ui.details_access_caption": "Le facce indicano se un utensile può raggiungerle dalla direzione scelta.", + "ui.details_angle": "Angolo", + "ui.details_continuity_caption": "Modifica la giunzione selezionata. Le altre giunzioni mantengono le proprie impostazioni.", + "ui.details_continuity_stale": "Geometria precedente: ripeti l'analisi per modificare o inquadrare questa giunzione.", + "ui.details_copy_list": "Copia elenco", + "ui.details_curvature": "Tolleranza di curvatura", + "ui.details_curvature_caption": "Le facce sono colorate in base al loro raggio di curvatura locale.", + "ui.details_direction": "Direzione dell'utensile", + "ui.details_draft_caption": "Le facce sono colorate in base al loro angolo rispetto alla direzione di estrazione.", + "ui.details_edit_seam": "Modifica giunzione", + "ui.details_fixture": "Esempio di componente — solo dati di prova", + "ui.details_fixture_caption": "Esempio di componente con valori illustrativi; non è un risultato del modello.", + "ui.details_fixture_problem": "Esempio di rilevamento con un problema", + "ui.details_fixture_readout": "Esempio di valore misurato", + "ui.details_flip": "Inverti", + "ui.details_gap": "Gioco", + "ui.details_hatch": "Tratteggia le facce tagliate", + "ui.details_jump": "Salto di curvatura", + "ui.details_key": "Legenda", + "ui.details_offset": "Offset", + "ui.details_plane": "Piano", + "ui.details_pull": "Direzione di estrazione", + "ui.details_radius": "Raggio", + "ui.details_readout": "Lettura", + "ui.details_samples": "Campioni", + "ui.details_seam": "{body} · Spigolo {edge}", + "ui.details_settings": "Impostazioni", + "ui.details_sharp_gap": "Spigolo vivo o gioco", + "ui.details_show_all": "Mostra tutti ({count})", + "ui.details_smooth": "Liscia", + "ui.details_stripes": "Strisce", + "ui.details_target": "Inquadra la geometria", + "ui.details_worst_seams": "Giunzioni peggiori", + "ui.details_zebra_caption": "Le strisce riflesse mostrano quanto dolcemente si raccordano le facce.", "ui.diameter": "Diametro", "ui.dimension": "Quota", "ui.direct_interaction": "Interazione diretta", @@ -4133,6 +4492,7 @@ "ui.equation_y_of_x": "y(x)", "ui.equivalent_radius": "Raggio equivalente", "ui.expand": "Espandi", + "ui.explode_needs_two_components": "Vista esplosa allontana i componenti tra loro, quindi il progetto ne richiede due o più (Nuovo componente, Inserisci progetto o Crea componenti da corpi)", "ui.exportDxf": "Esporta DXF…", "ui.export_ellipsis_keymap": "Esporta…", "ui.export_keymap_tip": "Tasti, preferiti del riquadro S e barra degli strumenti, in un file", @@ -4421,12 +4781,17 @@ "ui.plugins_skipped": "Impossibile leggere {count} cartelle.", "ui.plugins_version": "{version}", "ui.point": "Punto", + "ui.point_cloud_add_filter": "Aggiungi filtro…", "ui.point_cloud_auto_range": "Intervallo automatico", "ui.point_cloud_color_by": "Colora per", "ui.point_cloud_file_colors": "Colori del file", + "ui.point_cloud_filter_by": "Filtra per", "ui.point_cloud_height_high": "Più alto", "ui.point_cloud_height_low": "Più basso", + "ui.point_cloud_legend_filter": "Mostrati e nascosti nella legenda sopra", "ui.point_cloud_no_file_colors": "Il file non contiene colori", + "ui.point_cloud_pause": "Pausa", + "ui.point_cloud_play": "Riproduci", "ui.point_cloud_single_color": "Colore singolo", "ui.point_cloud_size": "Dimensione dei punti", "ui.point_cloud_units": "Unità del file", @@ -4455,6 +4820,7 @@ "ui.properties.inertia_y": "Iyx, Iyy, Iyz", "ui.properties.inertia_z": "Izx, Izy, Izz", "ui.properties.measuring": "Misurazione…", + "ui.properties.measuring_bodies": "Misurazione… {done} di {total} corpi", "ui.properties.measuring_failed": "La misurazione si è interrotta prima del termine.", "ui.properties.principal_axis": "Asse principale {number}", "ui.properties.principal_moments": "Momenti principali", @@ -4522,6 +4888,7 @@ "ui.selectOther": "Seleziona altro…", "ui.selectTangentChain": "Seleziona catena tangente", "ui.select_all": "Seleziona tutto", + "ui.select_components_by_face": "Seleziona componenti (una faccia seleziona il suo componente)", "ui.selection": "Selezione", "ui.selection_modifiers": "Modificatori di selezione", "ui.selection_shift_add_only": "Maiusc+clic aggiunge senza rimuovere", @@ -4563,6 +4930,11 @@ "ui.sketch_intersect_selection_filter": "Filtro di selezione", "ui.sketch_modifiers": "Modificatori dello schizzo", "ui.sketch_project_geometry": "Geometria", + "ui.sketch_report.all_constrained": "Tutto è completamente vincolato.", + "ui.sketch_report.dof": "Gradi di libertà", + "ui.sketch_report.loose": "Non completamente vincolato", + "ui.sketch_report.status": "Stato", + "ui.sketch_report.title": "Rapporto dello schizzo", "ui.sketch_scale": "Scala schizzo", "ui.sketches": "Schizzi", "ui.slice": "Genera sezioni", @@ -4672,7 +5044,14 @@ "ui.viewport_status_key": "Mostra la legenda di {name}", "ui.viewport_status_minimum_radius": "Raggio minimo", "ui.viewport_status_pairs": "{count} coppie", + "ui.viewport_status_section_hatch": "{setting} · tratteggio", + "ui.viewport_status_section_plane": "Piano attuale", + "ui.viewport_status_section_tilted": "inclinato · {offset}", "ui.viewport_status_stripes": "{count} strisce", + "ui.viewport_status_summary_clear": "Disattiva tutto", + "ui.viewport_status_summary_heading": "In questa vista", + "ui.viewport_status_summary_label": "{name} e altri {count} elementi", + "ui.viewport_status_summary_more": "+{count}", "ui.viewport_theme_follow": "Segui il tema", "ui.visual_shaded_hidden_edges": "Ombreggiato con spigoli nascosti", "ui.visual_shaded_with_edges": "Ombreggiato con spigoli", diff --git a/assets/locales/ja.json b/assets/locales/ja.json index 7daffce8b7..0a9b32d71a 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -822,6 +822,7 @@ "cmd.appearance_pick_up": "外観を取得", "cmd.appearance_replace": "外観を置換", "cmd.assembly_bom": "部品表", + "cmd.assembly_mass_properties": "質量特性", "cmd.body.print_face": "造形面", "cmd.body_rename": "ボディの名前を変更", "cmd.browser_collapse_all": "すべて折りたたむ", @@ -1247,6 +1248,7 @@ "cmd.view.render_browser": "Renderブラウザー", "cmd.view.render_dock": "レンダードックを移動", "cmd.view.render_focus": "Renderフォーカス表示", + "cmd.view.status_details": "ビューポートの状態の詳細", "cmd.view.status_items": "ビューポートの状態項目", "cmd.view_ambient_occlusion": "アンビエントオクルージョン", "cmd.view_angle_readout": "ビュー角度", @@ -1285,8 +1287,14 @@ "color.hex_hint": "#rrggbb", "color.hue": "色相", "color.linear": "0〜1の線形空間で色の値を表示", + "color.recent": "最近使った色", + "color.recent_help": "どの色フィールドで選んだ色もここに表示されます", + "color.saturation": "彩度(パーセント)", "color.saturation_value": "彩度と明度", "color.selected": "選択した色", + "color.step_down": "1 ステップ下げる", + "color.step_up": "1 ステップ上げる", + "color.value": "明度(パーセント)", "config.column_filter_empty": "一致する列がありません", "contact.components_count": "{count}個のコンポーネント", "contact.enabled_label": "有効", @@ -1333,6 +1341,7 @@ "dialog.dimension_name": "寸法: {name}", "dialog.edit_title": "{title}を編集", "dialog.no_shown_body": "変更できる表示中のボディがありません。ボディを表示するか、新規ボディを選択してください", + "dialog.pattern_sketch_not_object": "ボディかフィーチャーを選んでください。スケッチはどちらでもありません。曲線はスケッチ内のパターンかミラーで繰り返すか、先に押し出してください。", "dialog.select": "↖ 選択", "dialog.selected_count": "↖ {count} 個選択中", "dialog.selection_guidance": "入力欄を選んで変更します。選択済みのオブジェクトをクリックすると解除できます。Ctrl/Cmd を押しながらクリックしても切り替えられます。プレビューを確認してから OK を押してください。", @@ -1390,21 +1399,168 @@ "from the rule": "ルールから", "help.apache": "Apacheライセンス2.0", "help.appearance.assign": "選択した立体、面、コンポーネントに、重さを変えずに色や材質の見た目を付けます。部品を見分けるときや仕上げを確認するときに使います。", + "help.assembly.bom": "デザインまたはコンポーネントの部品を品番ごとに数量付きで一覧にし、リストをCSVまたはJSONで保存できます。発注や組み立て用の部品表として使います。", "help.attributions": "帰属表示 (ATTRIBUTION.md)", "help.build": "バージョン {version} · コミット {commit} · ビルド日 {date}", + "help.canvas.decal": "モデルの平面にステッカーのように画像を貼ります。ラベル、ロゴ、印刷柄を部品上に表示するときに使います。", + "help.canvas.edit": "キャンバスの位置、サイズ、角度、不透明度、反転、表示、名前を変更します。参照画像を、その上でトレースするスケッチに合わせるときに使います。", + "help.canvas.insert": "PNG または JPEG 画像を、半透明の参照として平面や面に配置します。既知のサイズに合わせた画像をもとに、写真や図面をスケッチでトレースするときに使います。", + "help.component.break_link": "リンクされたコンポーネントをデザイン ファイルから切り離し、ボディはそのまま残します。部品が元ファイルの変更に追従しないようにするときに使います。", + "help.component.capture_position": "手で動かした部品の位置を、その部品の新しい位置として確定します。ジョイントで保持されていない部品をドラッグして配置した後に使います。", + "help.component.create": "新しい空のコンポーネントをデザインに追加します。別の親を選ばない限り、アクティブなコンポーネントの中に作られます。アセンブリの新しい部品に、ボディやスケッチをまとめる場所を用意するときに使います。", "help.component.finish_edit": "その場で編集中の部品を保存し、アセンブリに戻って、その部品でアセンブリを更新します。リンクされた部品への変更が終わったときに使います。", + "help.component.from_bodies": "選択した各ボディを、そのボディ名を付けた個別のコンポーネントにします。ばらばらのボディで作ったデザインを、ジョイントで結合して動かせるアセンブリにするときに使います。", + "help.component.insert_design": "保存済みの別のデザインを、そのファイルにリンクしたコンポーネントとして挿入します。ソリッド ボディは元のデザインと同じ位置に置かれます。部品をアセンブリで再利用し、後の変更を「リンクを更新」で取り込むときに使います。", + "help.component.properties": "コンポーネントの体積、質量、重心、慣性モーメントを、各ボディの材料から求めて表示します。アセンブリの重さやバランスを確かめるときに使います。", + "help.component.revert_position": "手で動かした部品を元の位置に戻し、キャプチャしていない移動を破棄します。アセンブリで試しに動かした部品を元に戻すときに使います。", + "help.component.update_links": "リンクされたデザイン ファイルを読み込み直し、変更されたボディを置き換えます。このアセンブリで使っている部品を編集した後に使います。", + "help.config.configure": "パラメータ、フィーチャー、材料、外観を構成テーブルの列として追加します。部品のサイズ違いなど、1つのデザインのバリエーションを作るときに使います。", + "help.config.preview": "構成テーブルの行が、デザインや履歴を変えずに再構築できるか確認します。自信のないバリエーションに切り替える前に使います。", + "help.config.rules": "構成テーブルのルールと行、現在有効な行を表示します。デザインのバリエーションがどう設定されているかを確認するときに使います。", + "help.config.table": "構成テーブルを表示します。1行が1つのバリエーション、1列が1つのパラメータまたはフィーチャーで、デザインが有効な行と一致しているかも分かります。デザインのバリエーションを見直すときに使います。", "help.connect_note": "各アシスタントを再起動すると SolidCraft サーバーが読み込まれます。変更したファイルは .bak としてバックアップされています。", "help.connect_title": "AI アシスタントを接続", + "help.construct.axis.edge": "直線のエッジまたはスケッチ線に沿って構築軸を作ります。回転、円形パターン、ジョイントの軸として使います。", + "help.construct.axis.normal_to_face": "指定した点を通り、面に垂直な構築軸を作ります。表面からまっすぐ立ち上がる軸として使います。", + "help.construct.axis.through_cylinder": "円柱、円錐、トーラスの面、または円形のエッジの中心を通る構築軸を作ります。丸い部品のまわりでフィーチャーを回転させたりパターン化したりするときに使います。", + "help.construct.axis.two_planes": "2つの平面または平らな面が交わる位置に構築軸を作ります。必要な軸が2つの面の間の線であるときに使います。", + "help.construct.axis.two_points": "2つの点、頂点、スケッチ点を通る構築軸を作ります。位置が分かっている2か所の間を通る軸に使います。", + "help.construct.plane.along_path": "エッジまたは曲線に垂直な構築平面を、その上の指定位置に作ります。スイープ用のプロファイルや、パスの断面をスケッチするときに使います。", + "help.construct.plane.at_angle": "平面または面を、軸またはエッジを中心に指定した角度だけ傾けた構築平面を作ります。斜めのブラケットなど、傾いたスケッチに使います。", + "help.construct.plane.midplane": "2つの平面または平らな面の中間、または角度をなして交わる場合はその二等分に構築平面を作ります。ミラーの対称面として使います。", + "help.construct.plane.offset": "平面または平らな面に平行な構築平面を、指定した距離または点を通る位置に作ります。面から一定の高さの位置にスケッチするときに使います。", + "help.construct.plane.perpendicular": "線またはエッジを含み、平面または面に垂直な構築平面を作ります。エッジに沿ってモデル上に垂直にスケッチするときに使います。", + "help.construct.plane.tangent": "円柱、円錐、球、トーラスに1点で接する構築平面を作ります。丸い部品の側面にスケッチするときに使います。", + "help.construct.plane.three_points": "3つの点を通る構築平面を作ります。3つの角など、モデル上の位置で決まる平面に使います。", + "help.construct.plane.two_edges": "同一平面上にある2本の直線エッジまたは線を通る構築平面を作ります。向かい合う2本のエッジを通る面など、部品を横切る平面に使います。", + "help.construct.point.along_path": "エッジまたは曲線上の指定位置に構築点を作ります。パスの途中の基準として使います。", + "help.construct.point.center": "円形のエッジ、スケッチの円や円弧、球、トーラスの中心に構築点を作ります。穴、軸、ジョイントの基準として使います。", + "help.construct.point.edge_plane": "エッジまたは線が平面や平らな面を通る位置に構築点を作ります。パスが平面と交わる位置を示すときに使います。", + "help.construct.point.three_planes": "3つの平面または平らな面が交わる位置に構築点を作ります。モデルに頂点がない場所でも、角の基準として使えます。", + "help.construct.point.two_edges": "2本のエッジまたは線が交わる位置、または最も近づく位置に構築点を作ります。仮想の角の基準として使います。", + "help.construct.point.vertex": "頂点またはスケッチ点の上に構築点を作ります。周囲のスケッチやボディを非表示にしても残る基準として使います。", + "help.contact.check": "接触セット内で、現在の位置で互いに食い込んでいる部品の組を一覧表示します。ジョイントを駆動したりモーション スタディを再生したりする前に、干渉を見つけるときに使います。", + "help.contact.create": "2つ以上の部品を接触セットにまとめ、ジョイントが動くときにボディどうしが食い込まないようにします。レバーとストッパーのように、触れたところで止まるべき部品に使います。", + "help.contact.disable_all": "接触をオフにします。ジョイントの駆動やモーション スタディは、部品が触れても止まらなくなります。部品を再び自由に動かしたいときや、大きなアセンブリの動作を軽くしたいときに使います。", + "help.contact.edit": "接触セットを変更します。含まれる部品、名前、抑制の有無を変えられ、セットを削除することもできます。互いに食い込まないようにする部品を調整するときに使います。", + "help.contact.enable_all": "アセンブリのすべての部品を1つの接触セットに入れ、ジョイントが動くときにどの部品どうしも食い込まないようにします。機構全体をすばやく確認するときに使います。", + "help.contact.enable_sets": "定義した接触セットで接触をオンにします。ジョイントの駆動やモーション スタディは、セット内の部品が食い込む位置で止まります。接触をオフにした後、設定したセットに戻すときに使います。", + "help.contact.list": "デザインの接触セット、各セットに含まれる部品、接触がオンかどうかを一覧表示します。アセンブリを駆動する前に、接触の設定を確認するときに使います。", + "help.document.inspect": "デザインのコンポーネント、ボディ、スケッチ、フィーチャー、パラメータの概要を表示します。自分で作っていないデザインの全体像をつかむときに使います。", + "help.document.units": "デザインで長さを表示する単位を、mmやインチなどに変更します。形状の大きさは変わらず、表示と数値だけが単位に合わせて変わります。", + "help.edit.checkpoint": "現在のデザイン、アクティブなスケッチ、元に戻す履歴をそのまま記憶します。元に戻したくなるかもしれない危険な操作を試す前に使います。", + "help.edit.restore_checkpoint": "元に戻す履歴も含めて、チェックポイントの状態に戻ります。チェックポイント以降の作業をまとめて破棄するときに使います。", + "help.explode.create": "部品をアセンブリの中心から外側へ、または指定した移動量だけ離した分解図を作成します。組立手順書やプレゼンテーションで、アセンブリの組み立て方を示すときに使います。", "help.face.delete": "選択した面を削除し、隣の面でその穴をふさぎます。穴を開いたまま面として残すこともできます。じゃまな丸み、穴、突起を取り除くときに使います。", + "help.file.insert_mesh": "3MF、STL、OBJ、PLY、glTF ファイルを、新しいコンポーネント内のメッシュ ボディとしてデザインに追加します。ダウンロードやスキャンで得たモデルを、参照としてデザインの横に置くときに使います。", + "help.file.insert_step": "STEP、IGES、FreeCAD、Rhino ファイルを、新しいコンポーネント内のソリッド ボディとしてデザインに追加します。アセンブリの部品は入れ子のコンポーネントになります。メーカーやほかの CAD プログラムのモデルを取り込むときに使います。", + "help.file.save_mesh": "ボディを STL、3MF、OBJ のメッシュとして保存します。曲面のなめらかさは選択できます。3D プリント用や、メッシュを読み込むプログラム用に部品を用意するときに使います。", + "help.file.send_to_slicer": "選択したボディを 3MF ファイルとして保存し、選んだスライサーで開きます。モデルからプリントまでを1ステップで進めるときに使います。", "help.font_details": "インターフェースにはegui組み込みのフォントを使用しています(Ubuntu Font LicenceおよびMIT/Bitstream VeraライセンスのUbuntu LightとHack、OFLおよびMITのNoto Emojiと絵文字アイコンフォント)。スケッチのテキストにはLiberation Sans(SIL OFL 1.1)を使用しています。The Crafting Appsのオプションフォントパックcraft-fonts(BIZ UDPGothic、Shippori Mincho、BIZ UDMincho、Noto Sans CJK SC、Noto Sans Arabic、SIL OFL 1.1)はSolidCraftのビルドに同梱されていません。", "help.fonts": "フォント", + "help.frame.member": "チューブやアルミ押し出し材などのプロファイルを、フレームスケッチの線に沿って1本につき1部材ずつ配置します。フレーム、ラック、溶接構造を作るときに使います。", + "help.inspect.accessibility": "ツールが一方向から届く面と届かない面を色分けします。その方向からの切削加工や成形が可能か、部品を確認するときに使います。", + "help.inspect.center_of_mass": "一様な密度を仮定して、各ボディと全体の重心を表示します。バランスや、部品が倒れる位置を確認するときに使います。", + "help.inspect.colored_components": "外観を変えずに、コンポーネントごとに一時的な色を付けます。アセンブリの部品をひと目で見分けるときに使います。", + "help.inspect.compare": "2つのボディを比較し、一方にだけある材料の位置を体積と偏差とともに表示します。変更した部品やインポートした部品を元のものと照合するときに使います。", + "help.inspect.continuity": "面どうしがエッジに沿ってどれだけなめらかにつながるか(隙間、折れ、曲率の飛び)を調べ、継ぎ目を色分けします。サーフェスがきれいにつながっていない箇所を見つけるときに使います。", + "help.inspect.curvature_comb": "曲線、エッジ、面に、曲がりの強さを歯の長さで示すコームを描きます。平らな部分、折れ、曲率のむらを見つけるときに使います。", + "help.inspect.curvature_map": "面を曲がりの強さで色分けし、半径が小さいところを赤で示します。きつい箇所を見つけたり、サーフェスのなめらかさを判断したりするときに使います。", + "help.inspect.draft": "面を抜き方向に対する勾配角で色分けし、不足している面を示します。成形や鋳造の前に部品を確認するときに使います。", + "help.inspect.environment_map": "モデルを鏡のような反射する仕上げで表示します。反射の流れ方から、サーフェスの流れを判断するときに使います。", + "help.inspect.interference": "重なっているボディを見つけ、それぞれの重なりを体積とともに表示します。アセンブリで部品が干渉していないか確認するときに使います。", + "help.inspect.isocurve": "面のアイソパラメトリック曲線、つまりサーフェスのもとになるグリッドを描きます。サーフェスの流れや、線が密集している箇所を確認するときに使います。", + "help.inspect.measure": "選択したものの間の長さ、距離、角度、面積、半径、またはボディの体積と質量を測定します。寸法やクリアランスを確認するときに使います。", + "help.inspect.minimum_radius": "曲線、エッジ、面の最小曲率半径とその位置を求めます。工具やノズルが形状に沿って動けるか確認するときに使います。", + "help.inspect.section": "モデルを変更せずに、平面でビューを切断し、切断面にハッチングを付けます。部品やアセンブリの内部を見るときに使います。", + "help.inspect.zebra": "モデルの面に映る白黒のストライプを表示します。なめらかさの判断に使い、エッジでストライプが途切れたり折れたりする箇所は、つながりが悪いことを示します。", + "help.joint.as_built": "2つの部品を、どちらも動かさずに現在の位置で結合し、残る動きを設定します。すでに最終的な位置に置いた部品に使います。", + "help.joint.cam": "回転するカムとフォロワを連動させ、カムの輪郭に沿ってフォロワがスライドまたは揺動するようにします。カム、偏心機構、バルブ駆動に使います。", + "help.joint.create": "選んだジョイント原点で2つの部品を結合し、2つ目の部品をその位置へ移動します。残る動きはジョイントの種類で決まります。実際の機構と同じように動くアセンブリを作るときに使います。", + "help.joint.drive": "ジョイントの角度または距離を制限の範囲内で設定し、部品をそれに合わせて動かします。連動するジョイントや閉ループも追従します。機構が意図どおりに動くか確認するときに使います。", + "help.joint.gear": "2つの回転ジョイントをギア比で連動させ、一方を駆動するともう一方が回るようにします。平歯車や内歯車に使い、ギア比はピッチ半径または歯数から決めます。", + "help.joint.limits": "ジョイントが回転またはスライドできる範囲を最小値と最大値で設定するか、その制限を解除します。ヒンジ、スライダー、レバーを実際の可動範囲内に収めるときに使います。", + "help.joint.motion_link": "2つのジョイントを比率とオフセットで連動させ、一方を駆動するともう一方が動くようにします。「ギア」「ラック アンド ピニオン」「ねじ」では表せない、ジョイント間の一定の関係に使います。", + "help.joint.origin": "部品に名前付きのジョイント原点を置きます。面や丸い端の中心、エッジの中点、角、または点に置けます。ジョイントの取り付け位置を準備するときに使います。面上で選んだ原点は、部品のサイズが変わってもその面に追従します。", + "help.joint.rack_pinion": "回転するピニオンとスライドするラックを連動させ、ピニオンのピッチ円が転がった長さだけラックが移動するようにします。ラック駆動、ステアリング、リニア ステージに使います。", + "help.joint.reach": "部品上の点を目標に向けて動かし、そのチェーン上のジョイントを制限の範囲内で変化させます。ロボット アームの先端のように、先端の位置から機構の姿勢を決めるときに使います。", + "help.joint.rigid_group": "選択した部品を現在の配置のまま固定し、一体として動くようにします。互いに動きのない、ひとまとまりの部品に使います。", + "help.joint.screw": "回転とスライドをピッチで連動させ、1回転ごとにその距離だけ進むようにします。送りねじ、ボルト、ねじ軸に使います。", + "help.library.apply": "ローカルのライブラリファイルから材料または外観を、ボディ、面、コンポーネントに適用します。別のデザインで保存した材料を再利用するときに使います。", + "help.library.export": "デザインの材料と外観をライブラリファイルに保存します。共有したり、ほかのデザインで再利用したりするときに使います。", + "help.library.inspect": "デザインを変更せずに、ライブラリファイルを確認して材料と外観を一覧にします。適用する前にライブラリの中身を確認するときに使います。", "help.licences": "ライセンス", "help.material.assign": "選択した立体の材料を、鋼、アルミニウム、ABS などに設定します。質量と重さを実際の部品に合わせるときに使います。", + "help.material.properties.validate": "材料データカードの数値に矛盾や欠けがないか確認します。質量や剛性の計算にデータを使う前に使います。", + "help.mesh.convert": "STLなどのメッシュボディを、平面と円柱面を持つ編集可能なソリッドに作り直します。インポートした機械部品を、モデリングしたボディのように変更するときに使います。", "help.mit": "MITライセンス", + "help.motion.study": "タイムライン上のステップごとにジョイントの値を設定し、アニメーションとして再生します。機構が一連の動作をする様子を見て、途中のすき間を確認するときに使います。", + "help.motion.trace": "モーション スタディの再生中やジョイントの駆動中に部品上の点がたどる軌跡を求め、確定すると3D スケッチ曲線として残します。機構の届く範囲を確認したり、その軌跡をモデリングに利用したりするときに使います。", "help.notice": "NOTICE", + "help.occurrence.ground": "部品をその場に固定するか、固定を解除します。ジョイントはほかの部品をこの部品の周りで動かします。アセンブリの残りが組み付けられるフレームやベースに使います。", "help.occurrence.move": "アセンブリ部品をジョイントが許す自由度だけで動かします。部品の面を選択してから、操作可能な矢印または円弧をドラッグしてください。", "help.parameters.change": "デザインの寸法を決める名前付きの値と式の表を開きます。1か所で寸法を変え、それを使うすべてのフィーチャに反映させるときに使います。", + "help.parameters.export": "デザインのパラメータをCSVまたはJSONで保存します。表計算ソフトで編集したり、別のデザインに移したりするときに使います。", + "help.parameters.graph": "どのパラメータとフィーチャーがほかのどれを使っているか、その間のループやエラーも表示します。パラメータを変更すると何に影響するかを確認するときに使います。", + "help.parameters.import": "CSVまたはJSONからパラメータをデザインに読み込みます。すべて読み込むか、まったく読み込まないかのどちらかです。表計算ソフトや別のデザインの値を取り込むときに使います。", + "help.parameters.move": "ユーザーパラメータを、一覧内の別のパラメータの前または後ろに移動します。関連するパラメータをまとめておくときに使います。", + "help.parts.fastener": "選んだサイズの標準のボルト、ナット、ワッシャー、ピン、ベアリングを追加します。穴の上に置くとジョイントで穴に収まるので、締結部品を自分でモデリングする必要はありません。", + "help.parts.insert": "選んだ種類、サイズ、長さの標準パーツを挿入します。穴の縁や内壁に置くと、剛体ジョイントで穴に取り付けられます。", + "help.parts.library": "標準パーツを開きます。ボルト、ナット、ワッシャー、止めねじ、ダウエル ピン、ベアリングを規格サイズで用意しています。標準部品を自分でモデリングせずに追加するときに使います。", + "help.parts.resize": "挿入済みの標準パーツのサイズや長さを変更して再構築します。ジョイントにより取り付け位置は保たれます。ボルトが長すぎたときや、穴に別のサイズが必要なときに使います。", + "help.plastic.assign_rule": "プラスチック ルールをボディに割り当て、そのプラスチックフィーチャーが勾配とクリアランスを初期値として使うようにします。成形品を一度の設定ですべてのフィーチャーに反映するときに使います。", + "help.plastic.boss": "面にスクリューボスを追加します。穴、勾配、根元のフィレット、補強リブを任意で付けられる円柱です。成形品や3Dプリントのハウジングのねじ止め箇所に使います。", + "help.plastic.lip": "シェルしたボディの縁に沿って、リップまたはそれに合う溝を追加します。ハウジングの2つの半体が互いに位置決めされ、しっかり閉じるようにするときに使います。", + "help.plastic.manage_rules": "プラスチック ルール(材料、肉厚、半径、クリアランス、勾配)を作成・編集します。成形品のフィーチャーの一貫性を保つときに使います。", + "help.plastic.rest": "面にレストを追加します。丸または四角のパッドで、中実でも中空でも、勾配を付けられます。成形ハウジングの支えやスタンドオフとして使います。", + "help.plastic.snap_fit": "面にスナップフィットのフックを、長さ、厚さ、爪とともに追加します。ねじを使わずに部品を留めるときに使います。", "help.pointcloud.insert": "PLY・XYZ・PTS形式のスキャンを点群としてモデルと一緒に挿入します。計測データを参考にモデリングするときに使います。", + "help.sheet.convert": "厚さが均一なプレートまたはシェルしたボディを、選択したエッジで曲げた板金ボディにします。既存の部品を展開できるようにするときに使います。", + "help.sheet.export_bend_table": "板金ボディの曲げテーブル、つまり角度、半径、展開図上の曲げ位置をCSVまたはJSONで保存します。プレスブレーキや工場向けに使います。", + "help.sheet.export_dxf": "展開図を、外形、曲げ線、必要に応じて曲げの注記付きでDXFに保存します。レーザー、プラズマ、ウォータージェット切断用に使います。", + "help.sheet.flange": "フランジを追加します。スケッチから作るベースプレート、開いたスケッチ輪郭に沿った壁、または板のエッジに曲げた壁があります。板金部品を作り上げるときに使います。", + "help.sheet.flat_pattern": "板金ボディの展開図を、寸法、切り欠き、曲げ線とともに作成します。切断する前にブランクを確認するときに使います。", + "help.sheet.fold": "板をスケッチ線に沿って、指定した角度と半径で曲げます。フィーチャーを切り込んだ平らな板を折り曲げるときに使います。", + "help.sheet.hem": "板のエッジを自身に折り返します。鋭いバリのない、安全で剛性のあるエッジにするときに使います。", + "help.sheet.manage_rules": "板金ルール(厚さ、曲げ半径、Kファクター、逃げ、隙間)を作成・編集します。工場の材料や工具に合わせるときに使います。", + "help.sheet.path_flange": "板のエッジに沿って、開いたスケッチ線の連なりに従い、連続したフランジを追加します。UやZ形のプロファイルを1ステップで作るときに使います。", + "help.sheet.perforate": "展開した板全体に、千鳥またはグリッド状に穴のフィールドを追加します。通気口、グリル、軽量化したパネルに使います。", + "help.sheet.refold": "展開した板金ボディを折り戻します。展開した板にフィーチャーを追加した後に使います。", + "help.sheet.unfold": "板金ボディのすべての曲げを展開します。曲げをまたぐ穴や切り込みを追加するときに使い、その後で折り戻します。", + "help.sketch.arc.center_point": "中心点まわりに、始点から終点までの円弧を描きます。穴や回転軸まわりの半径など、円弧の中心が決まっているときに使います。", + "help.sketch.arc.tangent": "直線または円弧の端点から、それに正接する円弧を続けて描きます。角ではなく滑らかな曲がりでつなぎたいときに使います。", + "help.sketch.arc.three_point": "始点から終点まで、3つ目の点を通る円弧を描きます。中心ではなく、通る点が決まっているときに使います。", + "help.sketch.arc3d": "3D スケッチをオンにして、3点を通る正確な円弧を空間内に描きます。スケッチ平面から外れるパスやガイドレールの曲がりに使います。", + "help.sketch.auto_constrain": "水平、平行、一致など、スケッチがほぼ満たしている拘束を追加し、完全に定義されるまで寸法を付けます。ラフに描いたスケッチを一度で固定したいときに使います。", + "help.sketch.auto_constrain_from_datum": "自動拘束と同様ですが、原点ではなく、選んだ基準点から位置を測ります。角や穴など、1つの基準点から部品を配置するときに使います。", + "help.sketch.blend_curve": "2本の曲線の端を、両方に正接する滑らかなスプラインでつなぎます。選べば曲率連続にもできます。曲線の間の隙間を折れ目なく埋めるときに使います。", + "help.sketch.break": "曲線を、ほかの曲線と交差する位置、または置いた点で分割します。曲線の一部に別の拘束を付けたり、その部分だけ削除したりするときに使います。", + "help.sketch.cancel": "スケッチを終了し、作成または編集を始めてからの変更をすべて破棄します。元に戻すで変更を復元できます。", + "help.sketch.centerline": "直線を、通常の直線と中心線(輪郭に含まれない軸)に切り替えます。回転、ミラー、対称拘束の軸として中心線を使います。", + "help.sketch.chamfer.distance_angle": "2本の直線の角を、1本目の直線に沿った距離と角度で面取りします。2 mm × 30° のように、図面で長さと角度が指定されているときに使います。", + "help.sketch.chamfer.equal_distance": "2本の直線の角を、両方に同じ距離だけ戻って面取りします。外形の一般的な 45° 面取りに使います。", + "help.sketch.chamfer.two_distance": "2本の直線の角を、それぞれの直線に沿った別々の距離で面取りします。45° ではない、2つの長さで指定された面取りに使います。", + "help.sketch.circle.center": "中心と直径から円を描きます。中心が決まっている穴、ボス、そのほかの丸い形状に使います。", + "help.sketch.circle.three_point": "3点を通る円を描きます。既存のジオメトリ上の点など、円が通る点が決まっているときに使います。", + "help.sketch.circle.three_tangent": "3つの直線、円、円弧に接する円を描きます。三角形の内接円のように、3本の曲線の間に円をはめ込むときに使います。", + "help.sketch.circle.two_point": "直径の両端の2点から円を描きます。中心ではなく、円周上の点が決まっているときに使います。", + "help.sketch.circle.two_tangent": "2つの直線、円、円弧に接する、指定サイズの円を描きます。2本の曲線の間に半径一定の丸みを付けるときに使います。", + "help.sketch.conic": "2つの端点の間に、頂点と rho 値で形が決まる円錐曲線を描きます。平らな形からほぼ鋭い角まで、ふくらみを調整できる滑らかな遷移に使います。", + "help.sketch.constrainer": "選んだ1〜2本の曲線がほぼ満たしている拘束(水平、平行、正接、等長など)を適用します。手描きのスケッチを1つずつ整えるときに使います。", + "help.sketch.constraint.coincident": "点を、別の点、曲線、スケッチ軸の上に載せます。輪郭の隙間を閉じたり、点を直線上に置いたりするときに使います。", + "help.sketch.constraint.collinear": "2本の直線を同一直線上に揃えます。切り欠きの両側など、途切れたエッジを揃えるときに使います。", + "help.sketch.constraint.concentric": "2つの円または円弧の中心を揃えます。リング、座ぐり、段付きシャフトに使います。", + "help.sketch.constraint.curvature": "端点で接する2本の曲線を、同じ曲率で続けます (G2)。正接では滑らかさが足りない、見える面などに使います。", + "help.sketch.constraint.equal": "直線を同じ長さに、円や円弧を同じ半径にします。1つの寸法で複数の同じ形状を制御するときに使います。", + "help.sketch.constraint.fix": "点や曲線を現在の位置に固定、または固定を解除します。ドラッグやスケッチの解決で動いてはいけないジオメトリに使います。", + "help.sketch.constraint.horizontal_vertical": "直線、または2点を結ぶ線を、近いほうに合わせて水平または垂直にします。外形をスケッチの軸に揃えるときに使います。", + "help.sketch.constraint.midpoint": "点を直線の中央に置く、または2本の直線の中点を一致させます。エッジに対してジオメトリを中央に配置するときに使います。", + "help.sketch.constraint.parallel": "2本の直線を平行にします。ほかの寸法を変えても平行を保つ辺に使います。", + "help.sketch.constraint.perpendicular": "2本の直線を直角に交わらせます。スケッチ軸上にない直線同士の直角の角に使います。", + "help.sketch.constraint.polygon": "閉じた直線の連なりを正多角形にします。辺の長さが等しく、すべての角が1つの円上に載ります。手描きの多角形を正多角形にして、そのまま保つときに使います。", + "help.sketch.constraint.symmetry": "2つの点または曲線を、直線を挟んで、または点を中心に対称にします。片側を変更しても輪郭の対称性を保つときに使います。", + "help.sketch.constraint.tangent": "直線と円弧など、2本の曲線を正接させます。折れ目のない滑らかな接続に使います。", + "help.sketch.construction": "曲線を、通常のジオメトリと、輪郭に含まれないコンストラクション ジオメトリに切り替えます。レイアウト、参照、寸法の補助としてコンストラクション曲線を使います。", "help.sketch.construction_line": "2点を通る無限の補助直線を作成します。スケッチの輪郭を分割せずに作図できます。", "help.sketch.construction_line.angle": "スケッチのX軸に対して角度を指定し、無限の補助直線を作成します。点を選び、角度を入力するか方向を選びます。", "help.sketch.construction_line.bisector": "交差する2本のスケッチ直線を選び、2つの角の二等分線から選びます。結果は元の直線の編集に追従します。", @@ -1415,12 +1571,71 @@ "help.sketch.construction_line.tangent_external": "円または円弧、外部点、接線の解を順に選びます。プレビューには描かれた円弧上の接点のみが表示されます。", "help.sketch.construction_line.tangent_two": "2つの円または円弧を選び、プレビューから共通接線を選びます。接点は描かれた曲線上にある必要があります。", "help.sketch.construction_line.vertical": "指定点を通る無限の垂直補助直線を作成します。後で編集しても垂直拘束が維持されます。", + "help.sketch.copy": "選んだスケッチの曲線と点を、その間の拘束や寸法とともにコピーします。このスケッチまたは別のスケッチに貼り付けられます。", + "help.sketch.create": "平面、またはモデルの平らな面に、新しい 2D スケッチを開始します。押し出し、回転、スイープはすべてスケッチから始まります。", + "help.sketch.cut": "選んだスケッチ ジオメトリをコピーしてスケッチから削除します。操作は1回で元に戻せます。このスケッチ内の別の場所や別のスケッチに貼り付けられます。", + "help.sketch.dimension": "選んだ対象に応じて、長さ、距離、角度、半径、直径の駆動寸法を追加します。値またはパラメーター名を入力すると、スケッチがそれに追従します。", + "help.sketch.edit": "既存のスケッチを開いて編集します。スケッチを終了すると、それをもとにしたフィーチャが更新されます。", + "help.sketch.ellipse": "中心、長軸の端、短軸側の1点から楕円を描きます。長円形の開口部、カム、そのほか細長い丸い形に使います。", "help.sketch.equation_curve": "x(t) と y(t)、または y = f(x) の式から、範囲を指定して曲線を描きます。式にはパラメーターを使え、曲線はその変更に追従します。正弦波、らせん、歯車のインボリュートなどに使います。", + "help.sketch.extend": "直線または円弧を、行く手にある最も近い曲線まで延長します。2本の曲線を交わるまで伸ばすこともできます。トリムや編集のあとに輪郭の隙間を閉じるときに使います。", + "help.sketch.fillet": "2本の直線または円弧が交わる角を、指定した半径の円弧で丸めます。まとめて丸めた複数の角は同じ半径になります。", + "help.sketch.finish": "スケッチを終了してモデルに戻ります。閉じた輪郭は、押し出し、回転などのフィーチャに使えるようになります。", + "help.sketch.finish_auto_constrain": "スケッチに自動拘束を実行してから終了します。スケッチを完全に定義した状態で一度に終えるときに使います。", + "help.sketch.fit_curves_to_section": "メッシュまたはソリッド ボディをスケッチ平面で切り、断面に直線と円弧をフィットさせます。スキャンや読み込んだ部品を編集可能なスケッチとして作り直すときに使います。", + "help.sketch.include_3d": "モデルのエッジ、頂点、ほかのスケッチの曲線を、リンクされた参照としてこのスケッチに取り込み、平面外のものは平面上に平坦化します。スケッチ平面上にないジオメトリをもとに作図するときに使います。", + "help.sketch.infinite": "直線を、通常の直線と、ビュー全体に伸びる無限の補助直線に切り替えます。短すぎて足りなくなることのない、レイアウトの参照として使います。", + "help.sketch.insert_dxf": "DXF 図面の曲線をスケッチに読み込み、指定した位置に拡大縮小・回転して配置します。レーザー加工の外形など、ほかのプログラムで作った2D 図面をもとにモデリングするときに使います。", + "help.sketch.insert_svg": "SVG 図面のパスをスケッチに読み込み、指定した位置に拡大縮小・回転して配置します。ロゴやベクター輪郭を、押し出しできる曲線にするときに使います。", "help.sketch.inspect": "開いているスケッチのレポートを開きます。残りの自由度と、完全に拘束されているかどうかを表示します。スケッチを終える前に、まだ動く部分を確認するときに使います。", + "help.sketch.intersect": "ボディ、面、エッジ、ほかのスケッチがスケッチ平面と交差する曲線または点を追加します。リンクが保たれ、モデルが変わると追従します。", + "help.sketch.intersection_curve": "2つのボディまたは面が接する位置に、それらにリンクした 3D 曲線を作成します。モデルに追従するパスやガイドレールとして使います。", + "help.sketch.isoparametric_curve": "選んだ点を通る面の U または V 方向に沿った 3D 曲線を、面にリンクして作成します。表面に追従するパスや分割線として使います。", + "help.sketch.line": "点を順に指定して、つながった直線を開いた連なりまたは閉じた外形として描きます。ほとんどの輪郭に使う基本のツールです。", + "help.sketch.line.midpoint": "中点から一方の端まで直線を描き、もう一方の端は反対側に対称に作られます。対称なエッジなど、点を中心にした直線に使います。", + "help.sketch.line3d": "3D スケッチをオンにして、空間内の点の間につながった直線を描きます。スケッチ平面から外れるパスやフレームに使います。", + "help.sketch.mirror": "スケッチの曲線と点を直線または軸に対してミラーし、対称拘束を追加します。対称な輪郭の半分を描いて完成させるときに使います。", + "help.sketch.move": "スケッチの曲線と点、またはそのコピーを移動または回転します。形状と拘束を保ったままジオメトリの位置を変えるときに使います。", + "help.sketch.offset": "曲線の連なりを、指定した距離で片側または両側にコピーします。壁、外形、クリアランスに使い、距離は編集可能な寸法のままです。", + "help.sketch.paste": "コピーまたは切り取ったスケッチ ジオメトリを、アクティブなスケッチに貼り付けます。点を指定して配置するか、オフセットでずらせます。", + "help.sketch.pattern.circular": "スケッチ ジオメトリを中心点まわりに、全周または指定角度でコピーします。ボルト円、スポーク、歯に使います。", + "help.sketch.pattern.rectangular": "スケッチ ジオメトリを、指定した間隔で行と列にコピーします。穴、スロット、通気口の格子に使います。", + "help.sketch.point": "スケッチ点を配置します。寸法、拘束、穴の中心、後のフィーチャの参照として使います。", + "help.sketch.point3d": "3D スケッチをオンにして、空間内に点を配置します。3D 曲線やパスの参照として使います。", + "help.sketch.polygon.circumscribed": "円の外側に接する正多角形を、中心から辺の中央までのサイズで描きます。六角ナットのように、二面幅が決まっているときに使います。", + "help.sketch.polygon.edge": "1辺の両端から正多角形を描きます。辺の長さと位置が決まっているときに使います。", + "help.sketch.polygon.inscribed": "円に内接する正多角形を、中心から角までのサイズで描きます。対角のサイズが決まっているときに使います。", + "help.sketch.project": "モデルのエッジ、面、頂点、ほかのスケッチの曲線を、このスケッチの平面に投影して取り込みます。リンクが保たれ、モデルが変わると追従します。", + "help.sketch.project_to_surface": "ほかのスケッチの曲線を、このスケッチの法線方向に沿って面へ投影し、リンクされた 3D 曲線にします。ロゴ、分割線、パスを曲面に載せるときに使います。", + "help.sketch.rectangle.center": "中心と1つの角から長方形を描きます。原点など、点を中心にした外形に使います。", + "help.sketch.rectangle.rounded": "4つの角をすべて同じ半径で丸めた長方形を描きます。角の丸いプレート、フタ、開口部に使います。", + "help.sketch.rectangle.three_point": "1辺と、反対側の1点から長方形を描きます。スケッチ軸に対して傾いた長方形に使います。", + "help.sketch.rectangle.two_point": "対角の2点から長方形を描きます。プレート、ブロック、ポケットの外形に使います。", + "help.sketch.scale": "スケッチの曲線と点を、基準点を中心に倍率で拡大縮小します。描き直さずにスケッチの一部のサイズを変えるときに使います。", + "help.sketch.slot.arc_center": "中心、始点、終点で決まる円弧に沿って、指定幅の曲線スロットを描きます。回転軸まわりの調整用スロットに使います。", + "help.sketch.slot.arc_three_point": "3点を通る円弧に沿って、指定幅の曲線スロットを描きます。スロットの経路が決まった点を通る必要があるときに使います。", + "help.sketch.slot.center_point": "中心から一方の端の円弧の中心まで、指定幅の直線スロットを描きます。点を中心にしたスロットに使います。", + "help.sketch.slot.center_to_center": "両端の円弧の中心の間に、指定幅の直線スロットを描きます。ネジの調整用スロットなど、中心間で寸法を指定するスロットに使います。", + "help.sketch.slot.overall": "一方の外端からもう一方の外端まで、指定幅の直線スロットを描きます。スロットの全長が重要なときに使います。", + "help.sketch.spline.control_point": "通らない制御点で形が決まる3次スプラインを描きます。点を動かして形を整える、滑らかで流れるような曲線に使います。", + "help.sketch.spline.control_point_5": "制御点で形が決まる5次スプラインを描きます。見えるスタイリング面など、より滑らかな曲率で曲線をつなぎたいときに使います。", + "help.sketch.spline.fit_point": "置いたすべての点を通る滑らかなスプラインを、開いた形または閉じた形で描きます。既知の点を通して形をなぞるときに使います。", + "help.sketch.spline3d": "3D スケッチをオンにして、点を通る滑らかな曲線を空間内に描きます。スケッチ平面から外れるスイープ パスやケーブルの経路に使います。", + "help.sketch.spun_profile": "軸まわりに回転したボディが描く外形を、スケッチ平面上に作成します。回転や図面のために、旋削部品のシルエットを得るときに使います。", + "help.sketch.text": "選んだフォント、高さ、角度でスケッチに文字を書きます。文字はパラメーターに追従させられます。押し出しやエンボスでラベルや文字にします。", + "help.sketch.transform": "スケッチ ジオメトリを、中心点まわりに拡大縮小、回転、移動を一度に行います。選択した対象の位置とサイズをまとめて変えるときに使います。", + "help.sketch.trim": "クリックした位置の前後で、最も近い交差の間にある曲線の部分を削除します。重なった線を整えて閉じた輪郭にするときに使います。", "help.solid.align": "選択した立体を動かし、その点、面、平面を別の場所の点、面、平面に合わせます。距離を測らずに部品を別の部品に当てて配置するときに使います。", + "help.solid.boundary_fill": "重なり合うツール ボディに囲まれた領域からソリッドを作成し、選んだ領域だけを埋めます。複数のボディが囲む空間から形状を作るときに使います。", + "help.solid.bounding_solid": "選択したボディを囲むボックスを作成します。余裕寸法も指定できます。部品の全体寸法の確認や、加工前の素材形状として使います。", + "help.solid.box": "長さ、幅、高さからボックスを作成します。角の点から、または点を中心に配置できます。簡単なブロック、仮の形状、フィーチャを切り込む元のボディに使います。", "help.solid.chamfer": "選択した辺を、指定した距離で平らに斜めに削ります。鋭い角を落とすときや、ピンやねじを入れやすくするときに使います。", + "help.solid.coil": "直径、巻数、高さまたはピッチと、丸または角の断面から、ばねのようならせん状のボディを作成します。ばね、コイル、らせん状の突起に使います。", "help.solid.combine": "複数の立体を結合したり、対象の立体から切り取ったり、共通する体積だけを残したりでき、工具の立体を残すこともできます。部品の結合、ある形での切り抜き、2つの立体が重なる範囲の確認に使います。", + "help.solid.cone": "底面の円と高さから円錐を作成します。上面にも半径を指定すると円錐台になります。テーパー、漏斗、皿穴、先端部に使います。", + "help.solid.cylinder": "半径または直径と高さから、点の上に軸方向に立つ円柱を作成します。シャフト、ピン、ボス、丸い素材に使います。", "help.solid.draft": "選択した面を、中立面から測った指定の角度だけ傾けます。成形品や鋳物の部品を型から抜けるようにするときに使います。", + "help.solid.emboss": "テキストやロゴなどのスケッチ プロファイルを、面から浮き出させるか面に彫り込みます。平面や円筒面の文字、ラベル、刻印に使います。", "help.solid.extrude": "スケッチの輪郭を指定した距離だけ立体方向に押し出し、材料を足したり削ったりします。平面の図形をブロック、突起、ポケットにするときに使います。", "help.solid.fillet": "選択した辺を、指定した半径のなめらかな円弧で丸めます。強度や持ちやすさ、見た目のために鋭い角を和らげるときに使います。", "help.solid.hole": "面上の指定した位置に、指定した直径の丸い穴をあけます。ねじのクリアランスやピン穴など、単純な丸い開口に使います。", @@ -1429,20 +1644,60 @@ "help.solid.move": "選択した立体、またはそのコピーを、指定した距離と角度で移動・回転します。立体の位置を変えるときや、別の場所にもう1つ作るときに使います。", "help.solid.offset_face": "選択した面を指定した距離だけ内側または外側へ動かし、周りの面もそれに合わせます。スケッチを編集せずに、壁を厚く、ポケットを深く、突起を高くするときに使います。", "help.solid.pattern.circular": "選択したオブジェクトを、指定した数と全体の角度で軸のまわりに繰り返し配置します。ボルト円、スポーク、フィン、歯などに使います。", + "help.solid.pattern.path": "フィーチャーまたはボディを、スケッチのパスに沿って個数と間隔を指定してコピーします。曲線エッジに沿った穴や、レールに沿った部品に使います。", "help.solid.pattern.rectangular": "選択したオブジェクトを、指定した数と間隔で1方向または2方向に繰り返し配置します。穴、リブ、ピン、脚の列や格子状の配置に使います。", + "help.solid.pipe": "スケッチ曲線の経路に沿って、中実または肉厚付きの中空の丸パイプを作成します。チューブ、ワイヤー、ケーブル、手すりに使います。", "help.solid.press_pull": "面をドラッグして材料を足したり削ったりし、辺をドラッグして丸めます。モデル上で直接すばやく変更するときに使います。", "help.solid.remove": "タイムラインのこの位置から先で、選択した立体をデザインから取り除きます。それより前のステップには残ります。不要になった切り取り用の立体など、補助の立体を外すときに使います。", "help.solid.replace_face": "選択した平らな面を平面または平行な面の位置へ動かし、立体はそこまで伸び縮みします。面を別の部品や作業平面と面一にするときに使います。", "help.solid.revolve": "スケッチの輪郭を軸のまわりに回転させ、丸い立体を作ります。軸、つまみ、ボウルなど、中心線まわりに丸い部品に使います。", + "help.solid.rib": "開いたスケッチ曲線に沿って、ボディの次の面まで届く薄い壁を追加します。成形品や 3D プリント部品などで、壁や角を補強するときに使います。", "help.solid.scale": "選択した立体を、全方向に同じ倍率、または軸ごとの倍率で拡大・縮小します。読み込んだ形状の大きさを変えるときや、収縮分を見込むときに使います。", "help.solid.shell": "立体の中をくり抜き、指定した厚さの壁を残します。選んだ面を開口にすることもできます。箱、カバーなどの薄肉の部品に使います。", + "help.solid.sphere": "中心点の周りに、半径または直径から球を作成します。ボール、つまみ、丸い端部に使います。", "help.solid.split_body": "平面、別の立体の面、またはスケッチの線で、立体を別々の立体に分割します。別々に印刷、加工、着色する部品を作るときに使います。", "help.solid.split_face": "選択した面を、平面、別の立体の面、またはスケッチの曲線に沿って分割します。立体そのものは切りません。面の一部にだけ抜き勾配、色、オフセットを付けるときに使います。", "help.solid.sweep": "スケッチの輪郭をパスに沿って動かし、立体を作ります。パイプ、レール、ハンドルなど、一定の断面で曲線をたどる部品に使います。", + "help.solid.thread": "円筒面に ISO メートルねじを追加します。表示上のねじにするか、ボディに切り込むかを選べます。ボルト、ねじ穴、3D プリントのねじ部品に使います。", + "help.solid.torus": "軸周りの大半径と、丸い断面の小半径からリングを作成します。O リング、取っ手、丸いビードに使います。", + "help.solid.web": "開いたスケッチ曲線ごとに、ボディの次の面まで届く薄い壁を追加します。ハウジングやトレイ内部の格子状の補強壁に使います。", + "help.surface.blend": "2つのボディのエッジを橋渡しするサーフェスを、両方に接線連続または曲率連続で作ります。隙間を越えて2つのサーフェスをなめらかにつなぐときに使います。", + "help.surface.boundary": "2〜4本のスケッチ曲線のループ、または曲線ネットワークを埋めるサーフェスを作ります。エッジで定義される自由曲面のパネルに使います。", + "help.surface.extend": "サーフェスをエッジの先へ、指定した距離または平面や面まで延長します。トリムやステッチの前に、サーフェスどうしを重ねるために使います。", + "help.surface.extrude": "スケッチ曲線またはプロファイルを指定した距離だけ押し出し、キャップのない壁だけのサーフェスにします。薄い壁や、切断・分割用のツールに使います。", + "help.surface.fair": "エッジを動かさずに、自由曲面の内部をなめらかにします。サーフェスの凹凸や、波打つ反射をならすときに使います。", + "help.surface.loft": "2つ以上の断面を順に通るサーフェスを、キャップなしで作ります。シェルや、異なる形状の間の移行部に使います。", + "help.surface.match": "サーフェスのエッジを作り直し、別のボディのエッジに接線または曲率を合わせてつなぎます。2つのサーフェスの継ぎ目をなめらかに閉じるときに使います。", + "help.surface.offset": "面を法線方向に指定した距離だけ離したコピーを、新しいサーフェスボディとして作ります。スキン、クリアランス、壁の反対側を作るときに使います。", + "help.surface.patch": "スケッチ領域またはエッジの閉ループを、平らな面またはなめらかに湾曲した面で埋めます。サーフェスボディの穴を塞ぐときに使います。", + "help.surface.reverse_normal": "サーフェスボディの面を裏返して、外側の向きを反対にします。面が内向きになっているとき、厚み付けやステッチの前に使います。", + "help.surface.revolve": "スケッチ曲線またはプロファイルを軸のまわりに回転させ、キャップのないサーフェスにします。ランプシェード、ボウル、ノズルなどの丸いシェルに使います。", + "help.surface.ruled": "エッジの連なりから、面の法線方向、接線方向、または指定した角度に、直線でできた帯を作ります。勾配のある壁、ランオフ、分割面に使います。", + "help.surface.stitch": "サーフェスボディを共有するエッジで縫い合わせ、閉じる場合はソリッドにします。複数のサーフェスを1つのボディにまとめるときに使います。", + "help.surface.sweep": "スケッチ曲線またはプロファイルをパスに沿って動かし、キャップのないサーフェスにします。雨どいやトリムなど、パスに沿う薄い形状に使います。", + "help.surface.thicken": "サーフェスボディに厚みを与えて、ソリッドにします。サーフェスから3Dプリントや機械加工ができる部品を作るときに使います。", + "help.surface.trim": "平面、別のサーフェス、スケッチ曲線でサーフェスを切断し、選んだ側を残します。ステッチの前にサーフェスを形に切り出すときに使います。", + "help.surface.unstitch": "ボディの面を、個別のサーフェスボディに分離します。ソリッドはサーフェスにばらばらになります。ボディの一部の面だけを作り直すときに使います。", + "help.surface.untrim": "面から穴を取り除くか、外側の境界をサーフェス本来の範囲に戻します。トリムした後で、元のサーフェス全体を取り戻すときに使います。", "help.third_party": "サードパーティライセンス", "help.timeline.compute_all": "タイムラインのすべてのフィーチャを最初から作り直します。モデルを更新し、どのフィーチャが失敗するかを確認するときに使います。", "help.timeline.delete": "選択したフィーチャをタイムラインから削除します。スケッチを削除すると、そこから作ったフィーチャも削除されます。不要になったステップを取り除くときに使います。", + "help.timeline.dependents": "フィーチャーと一緒に削除されるフィーチャーと、それがないと失敗するフィーチャーを一覧にします。フィーチャーを削除または変更する前に使います。", "help.timeline.play": "残りのフィーチャを一つずつ再生し、再構築のたびに0.5秒間停止します。このコマンドをもう一度実行するかEscキーで停止し、「最後へ」で直ちに最後へ移動します。", + "help.view.apply": "ビューをフロント、トップ、アイソメなどの標準方向、または保存した名前付きビューに回転します。固定した角度からモデルを見るときに使います。", + "help.view.delete": "保存した名前付きビューを削除します。モデルは変更されません。", + "help.view.fit": "モデル全体、または選択したものがビューいっぱいになるように、ズームと移動をします。モデルが視界から外れたときに使います。", + "help.view.grid": "ビューポートのグリッドを表示または非表示にします。モデリング中の大きさと向きの目安として使います。", + "help.view.ground_shadow": "地面に落ちるモデルの影を表示または非表示にします。高さや位置を判断しやすくするときに使います。", + "help.view.home": "ビューをホームの方向に戻し、モデルをフィットさせます。決まったビューにすばやく戻るときに使います。", + "help.view.list": "デザインに保存されている名前付きビューを一覧にします。以前に保存したビューを探すときに使います。", + "help.view.navigate": "左に90度回転するなど、ステップごとにビューを回転、移動、ズームします。ドラッグではなく、カメラを正確に動かすときに使います。", + "help.view.navigation_bar": "ビューポート下部のナビゲーションバーを表示または非表示にします。広く使いたいときは非表示に、マウスでビューツールを使うときは表示にします。", + "help.view.rename": "保存した名前付きビューの名前を変更します。ビュー自体は変わりません。", + "help.view.save": "現在のカメラ位置を名前付きビューとしてデザインに保存します。スクリーンショット用など、後で同じ角度に戻るときに使います。", + "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": "削除を確認", @@ -1656,6 +1911,7 @@ "nav.hfss.help": "Alt+左ドラッグでパン、Shift+Alt+左ドラッグでズーム、何もない所からの Ctrl+左ドラッグで回転。", "nav.inventor.help": "中ドラッグでパン、Shift+中ドラッグで回転、Ctrl+中ドラッグでズーム、右ドラッグで回転。", "nav.maya.help": "Alt+左ドラッグで回転、Alt+中ドラッグでパン、Alt+右ドラッグでズーム。中ドラッグでもパン。", + "nav.nx.help": "中ボタンのドラッグで回転、Shift+中ボタンで平行移動(NXの中ボタン+右ボタン)、Ctrl+中ボタンでズーム(NXの中ボタン+左ボタン)。右ボタンのドラッグではカメラは動きません", "nav.openscad.help": "空きスペースで左ドラッグすると回転、Shift+左ドラッグで矩形選択、スケッチ ジオメトリはドラッグで編集。右または中ドラッグでパン、Shift+右または Shift+中ドラッグでズーム。", "nav.perspective.sketch": "スケッチ編集中は平行投影で表示されます。透視投影に切り替えるにはスケッチを終了してください。", "nav.right_drag_orbits.help": "オフ:右ドラッグではカメラは動かず、右ボタンはコンテキストメニューを開くだけです。マウス割り当てで SolidWorks を選ぶとオフになります。", @@ -1877,6 +2133,8 @@ "prefs.ctrl_cmd": "Ctrl / Cmd", "prefs.curved_surfaces": "曲面", "prefs.curves": "曲面", + "prefs.dark_parts_shading": "暗い部品の形状を保つ", + "prefs.dark_parts_shading_help": "リニア光と柔らかいスカイライトでビューをシェーディングし、暗い面が黒くつぶれずにグラデーションを保つようにします", "prefs.dead_zone_note": "斜線の帯はデッドゾーンです。この範囲内の動きは無視されます。", "prefs.default": "既定", "prefs.design": "設計の既定値", @@ -1891,6 +2149,8 @@ "prefs.edges": "エッジと陰影", "prefs.extend": "拡張", "prefs.extra_navigation": "何もない場所でCtrl+左ドラッグすると回転し、Spaceを押しながらドラッグすると移動します。", + "prefs.extrude_view_isometric": "押し出しを開いたときに等角ビューにする", + "prefs.extrude_view_return": "押し出しを閉じたときに元のビューに戻す", "prefs.follows_os": "OS の設定に従う", "prefs.font": "フォント", "prefs.font_fallback": "選択したフォントにない文字は組み込みフォントで表示し、ラベルが空の四角になるのを防ぎます。", @@ -1906,6 +2166,7 @@ "prefs.graphics_card_auto": "自動", "prefs.graphics_card_missing": "保存されたグラフィックスカードが利用できないため、自動選択を使用します。", "prefs.graphics_card_restart": "SolidCraft の再起動後に有効になります。WGPU_ADAPTER_NAME がこの選択より優先されます。", + "prefs.graphics_card_unused": "保存したグラフィックスカードはこのウィンドウでは使われていません。使用中のカードは「ヘルプ › SolidCraft について」とステータス行に表示されます。自動を試すか、カード名の一意な部分を WGPU_ADAPTER_NAME に設定してください。", "prefs.graphics_note": "速度と滑らかさのバランスを調整します。各選択肢の効果をサンプルで確認できます。", "prefs.graphics_presets_note": "プリセットは以下の設定をまとめて変更します。", "prefs.hide_constraint_glyphs_zoomed_out": "ズームアウト時に拘束グリフを隠す", @@ -1920,6 +2181,8 @@ "prefs.history_sketches_note": "スケッチ:「フィーチャーの下」では、左側の履歴に名前が表示される幅があるとき、スケッチをそれから作成した最初のフィーチャーの下に 1 段字下げして表示します。下部のタイムラインは常に作成順です。", "prefs.history_sketches_under_feature": "フィーチャーの下", "prefs.hold_stop_snapping": "押している間スナップを解除", + "prefs.home_fit_size": "ホームと全体表示の大きさ", + "prefs.home_fit_size_note": "ホーム、全体表示、開いたデザイン、ナビゲーターでの視点変更がモデルを画面に収めるときに、モデルがビューに占める割合。下の設定のオン・オフにかかわらず適用されます。100 %: パネル、コンパス、ナビゲーションバーの間のビューをいっぱいに使います。", "prefs.icons_only": "アイコンのみ", "prefs.imported_file": "読み込んだファイル", "prefs.in_browser": "ブラウザー内", @@ -1928,13 +2191,20 @@ "prefs.interface_language": "表示言語", "prefs.interface_note": "SolidCraft の外観と各パネルの配置。", "prefs.interval_minutes": "{n} 分", + "prefs.invert_navigator_arrows": "ビューキューブの回転矢印を反転", + "prefs.invert_navigator_arrows_note": "キューブの周りの矢印が、矢印の指す方向にカメラを回します(Fusion や Inventor と同じ動き):左の矢印は右を指し、カメラを右に振ります。オフ: 矢印は、そのある面の方へキューブを回すため、左の矢印はカメラを左に振ります。カーブしたロール矢印は影響を受けません。", + "prefs.keep_work_on_tab_switch": "デザインを切り替えてもダイアログとツールを開いたままにする", + "prefs.keep_work_on_tab_switch_note": "各デザインのタブが、開いているダイアログまたはツール(選択や入力した内容を含む)とツールバーのタブを保持します。デザインに戻ると元のまま表示されます。オフ: デザインを切り替えるとそれらはキャンセルされ、モデル タブが表示されます。", "prefs.labels": "ラベル", + "prefs.language_system": "システム", "prefs.layout": "レイアウト", "prefs.layout_note": "コンパクトではパネルをウィンドウの端に固定します。ブラウザーの半透明表示はフローティングで使用できます。アシスタントの配置はアシスタントページで変更します。", "prefs.left": "左", "prefs.left_click": "左クリック", "prefs.line_drag_draws_one": "押してドラッグすると線を 1 本描く", "prefs.line_drag_draws_one_note": "線ツールで押してドラッグし、離すと、押した位置から離した位置まで線を 1 本描いてそこで終わります。クリックでは従来どおり連続した線を描きます。オフ: ドラッグはクリックと同じく、離した位置に点を置きます。", + "prefs.line_hover_tangent_arc": "線の最後の点で止めると接線円弧に切り替える", + "prefs.line_hover_tangent_arc_note": "線ツールで線分を描いたあと、連続線の最後の点から一度離れて戻り、その点の上でポインターを止めると、次のクリックで接線円弧の終点を指定します。同じ操作で線に戻ります。オフ: 止めても何も起こりません。", "prefs.linked_designs": "リンクされたデザイン", "prefs.live_input": "現在の入力", "prefs.live_preview": "ライブプレビュー", @@ -1958,6 +2228,8 @@ "prefs.mouse_mapping": "マウスの割り当て", "prefs.navigation": "ナビゲーション", "prefs.navigation_note": "マウス、トラックパッド、3Dマウスによるビューの操作。", + "prefs.navigator_fit": "ナビゲーターでの視点変更後にモデルを全体表示", + "prefs.navigator_fit_note": "キューブの面・角・矢印やギズモの軸をクリックすると、どの方向からでも全体が収まる一定の距離(外接球)からモデルの中心を見ます。オンの間はホームビューも同じズームと中心を使うため、その後に選んだ視点は向きだけが変わります。オフ: 視点はその場で回転し、注視点とズームを保ちます。", "prefs.network_host_remove": "削除", "prefs.network_locations": "デザイン内のネットワーク上の場所を許可", "prefs.network_locations_note": "オフ: 別のコンピューター (\\\\server\\share) にあるデザインのリンク先ファイルは、ここに表示されたサーバー、または確認時に許可したサーバーからのみ開きます。", @@ -2006,6 +2278,8 @@ "prefs.render_previous_geometry_budget": "レンダープレビューで以前のジオメトリ予算を使用", "prefs.render_previous_geometry_budget_help": "デバイスに基づく予算の代わりに、以前の固定ジオメトリ上限(64ビットデスクトップでは512 MiB、その他では128 MiB)に戻します。", "prefs.reports_saved": "{n} 件保存済み", + "prefs.ribbon_click_ends_tool": "実行中のツールのボタンをクリックすると終了する", + "prefs.ribbon_click_ends_tool_note": "使用中のスケッチツールの点灯したリボンボタンをクリックするとツールが終了し、ビューのクリックで再び選択できます。オフ: クリックするとツールが最初から開始されます。", "prefs.ribbon_reveal": "検索結果をリボンに表示", "prefs.ribbon_reveal_note": "コマンド検索で結果にポインターを合わせると、そのコマンドがリボンのどこにあるか(タブ、ボタン、またはパネルのドロップダウン内の行)を表示します。検索から開始したコマンドはそこで点滅し、ドロップダウンから実行中のコマンドはそのパネルに印が付きます。", "prefs.right_click": "右クリック", @@ -2035,6 +2309,8 @@ "prefs.sketch_exit_view.previous": "前のビューに戻る", "prefs.sketch_exit_view.unless_rotated": "回転していなければ戻る", "prefs.sketch_exit_view_note": "「スケッチを終了」、キャンセル、Esc の 2 回押しの後にビューが移る先です。", + "prefs.sketch_face_activates_part": "部品の面にスケッチするとその部品をアクティブにする", + "prefs.sketch_face_activates_part_note": "別のコンポーネントのボディの面で始めたスケッチは、先にそのコンポーネントをアクティブにし、スケッチはそのコンポーネントに属してブラウザでその部品の下に表示されます。部品はその後もアクティブのままです。オフ:スケッチはアクティブなコンポーネントに属します。", "prefs.sketch_open_look_at": "スケッチを開いたときに正対させる", "prefs.sketch_open_look_at_note": "オフ: スケッチの作成や編集でビューを動かしません。必要なときは「正面視」でスケッチに正対します。", "prefs.sketch_recent_snap": "最近使った中心と中点へ自動スナップ", @@ -2048,6 +2324,8 @@ "prefs.sketch_tab_always_note": "スケッチを開いていないときも、スケッチ タブをリボンのモデルのすぐ後に表示します。そこにある作図ツールは、選択した平面または面に、なければ次に選ぶ平面にスケッチを開始し、そのスケッチでツールをすぐ使える状態にします。", "prefs.sketch_tracking_guides": "中心・中点の整列ガイド", "prefs.sketch_tracking_guides_note": "円の中心や直線辺の中点にカーソルを合わせ、水平・垂直に移動すると一時的な点線ガイドを表示します。", + "prefs.sketch_tracking_points": "作図中は端点と角からも整列ガイド", + "prefs.sketch_tracking_points_note": "作図ツールが点を待っている間、線や円弧の端点、長方形の角、独立した点にカーソルを合わせると、中心や中点と同じ一時的な点線ガイドを表示します。オフ: 中心と中点のみ。", "prefs.sketching": "スケッチ", "prefs.snap_drawing_note": "スケッチの描画中または点のドラッグ中に使用します。", "prefs.source": "入力元", @@ -2064,6 +2342,24 @@ "prefs.status_error": "エラー", "prefs.status_selected": "選択中", "prefs.status_warning": "警告", + "prefs.structure.appearance": "外観", + "prefs.structure.appearance_note": "色、文字、アクセシビリティ。", + "prefs.structure.in_profile": "プロファイルに保存", + "prefs.structure.layout": "レイアウト", + "prefs.structure.layout_note": "ウィンドウ内の配置。", + "prefs.structure.look": "表示", + "prefs.structure.measure_pointer": "計測とポインター", + "prefs.structure.mouse3d": "3Dマウス", + "prefs.structure.mouse3d_note": "SpaceMouse と SpaceNavigator。", + "prefs.structure.navigation_note": "マウスとトラックパッド。3Dマウスは専用ページにあります。", + "prefs.structure.profile": "プロファイル", + "prefs.structure.profile_edited": "保存後に変更あり", + "prefs.structure.profile_hover": "設定プロファイルに保存されます", + "prefs.structure.scale_percent": "{percent}%", + "prefs.structure.sketch": "スケッチ", + "prefs.structure.tools": "ツール", + "prefs.structure.tools_note": "モデリングツールの動作。", + "prefs.structure.work": "作業", "prefs.supersampling_brief": "大きく描画して縮小するため、非常に鮮明になりますが負荷も増えます。", "prefs.tab_accent_note": "アクセント色はアクティブなタブに従います", "prefs.theme_file": "テーマファイル", @@ -2075,6 +2371,16 @@ "prefs.theme_follow_off": "オフ", "prefs.theme_follow_stop": "追従を停止", "prefs.theme_import_hint": "クリックしてテーマファイルを選ぶか、ここにドロップするか、テーマをコピーした状態で Ctrl+V(Mac では Cmd+V)を押します", + "prefs.theme_option_opacity": "不透明度", + "prefs.theme_option_opacity_help": "フローティングパネルの不透明度を調整します。ビューポートの色に重なっても文字が読めるように下限が適用されます。", + "prefs.theme_option_tab_colours": "タブの色", + "prefs.theme_option_tab_colours_help": "リボンタブごとにテーマのパネルとビューポートの色を使用します。", + "prefs.theme_option_tab_fade_ms": "タブの切り替え", + "prefs.theme_option_tab_fade_ms_help": "タブの切り替え時にパネルとビューポートの色を徐々に変えます。0で即座に切り替えます。", + "prefs.theme_options_badge": "オプション", + "prefs.theme_options_more": "その他 · {count}", + "prefs.theme_options_reset": "リセット", + "prefs.theme_options_unavailable": "このデバイスでは利用できません", "prefs.theme_paste": "テーマを貼り付け", "prefs.theme_paste_none": "クリップボードに SolidCraft のテーマがありません", "prefs.timeline_half_hour": "−30 分", @@ -2109,6 +2415,8 @@ "prefs.viewport_background_theme": "テーマのグラデーション", "prefs.viewport_section_status": "ビューポートの断面状態を表示", "prefs.viewport_section_status_help": "ビューポートに断面設定を表示します。Escape または有効なリボンボタンで断面を解除します。無効にすると従来の断面操作を維持します。", + "prefs.viewport_status_details": "ビューポートのライブ詳細", + "prefs.viewport_status_details_help": "標準の詳細カードで、実行中の解析と断面の設定を編集します。無効にすると、読み取り専用の解析凡例と断面ダイアログに戻ります。", "prefs.viewport_status_summary": "ビューポートの状態項目をまとめる", "prefs.viewport_status_summary_help": "3 個以上の状態項目を 1 つのサマリーバッジにまとめます。無効にすると、すべての項目を従来の行に表示します。", "prefs.wheel": "ホイール", @@ -3549,6 +3857,7 @@ "sketch_text.formula": "= で始めるとパラメーターから文字列を組み立てられます: ='W' + number(Width)", "sketch_text.italic": "斜体", "sketch_text.loading_fonts": "インストール済みフォントを検索中…", + "sketch_text.no_bold": "組み込みフォントには太字がありません: インストール済みのフォントを選択してください", "sketch_text.on_path": "文字はパスに沿います: 位置は曲線で決まります", "sketch_text.path_clear": "文字を曲線から外す", "sketch_text.path_hint": "文字が沿うスケッチの曲線: ここをクリックしてから曲線をクリック", @@ -3757,6 +4066,7 @@ "ui.agent_chat_auth_terminal": "ターミナルでこれを実行してから、もう一度「接続」を押してください:", "ui.agent_chat_auth_title": "エージェントにサインイン", "ui.agent_chat_cancel": "キャンセル", + "ui.agent_chat_cannot_start": "インストールされていますが、起動しません: {error}", "ui.agent_chat_changes": "{n} 件の変更(元に戻すは1回)", "ui.agent_chat_changes_n": "{n} 件の変更", "ui.agent_chat_changes_of": "変更 {n}/{limit}", @@ -3849,6 +4159,7 @@ "ui.agent_chat_queued_stopped": "待機中(停止済み・未送信)", "ui.agent_chat_refresh": "再検索", "ui.agent_chat_refused": "エージェントには許可されていません: {reason}", + "ui.agent_chat_reinstall": "{agent} はインストールされています ({program}) が、SolidCraft が確認したときに起動しませんでした: {error}\nインストールが不完全か、別の種類のコンピューター用の可能性があります。`{install}` で再インストールし、ターミナルでバージョンが表示されることを確認してから、再検索を押してください。", "ui.agent_chat_remove": "待機から削除", "ui.agent_chat_remove_file": "このメッセージから外す", "ui.agent_chat_remove_imported": "このメッセージから外します。パーツはデザインに残ります (元に戻すで削除できます)", @@ -3912,12 +4223,17 @@ "ui.ai_copy_suffix": "コピー", "ui.ai_custom_url": "カスタム URL", "ui.ai_default": "既定", + "ui.ai_edit": "編集", + "ui.ai_edit_agent_title": "エージェントを編集", + "ui.ai_edit_gone": "このエージェントは削除されたため、保存するものはありません。", + "ui.ai_edit_tip": "このエージェントの名前、コマンド、エンドポイントを変更します。保存済みのキーは表示されません。", "ui.ai_http_busy": "エンドポイントが混雑しているか上限に達しています。後で再試行してください", "ui.ai_http_key": "エンドポイントがキーを拒否しました。「環境設定 > AI」で確認してください", "ui.ai_http_not_found": "エンドポイントまたはモデルが見つかりません。ベース URL とモデル名を確認してください", "ui.ai_http_too_large": "会話がこのエンドポイントには大きすぎます。新しいチャットを始めてください", "ui.ai_key": "API キー", "ui.ai_key_hint": "任意。システムのキーチェーンに保存", + "ui.ai_key_keep_hint": "空のままにすると保存済みのキーを使い続けます", "ui.ai_key_needs_https": "キーは HTTPS 経由、またはこのコンピューター宛てにのみ送信されます。", "ui.ai_key_note": "システムのキーチェーンに保存され、SolidCraft の設定には保存されません。ローカルサーバーの場合は空欄のままにします。", "ui.ai_keychain_error": "システムのキーチェーンが拒否しました: {error}", @@ -3944,11 +4260,15 @@ "ui.ai_permissions": "権限", "ui.ai_preset": "プリセット", "ui.ai_protocol": "プロトコル", + "ui.ai_reasoning_effort": "推論の強度", + "ui.ai_reasoning_effort_field_hint": "空 = プロバイダーの既定値", + "ui.ai_reasoning_effort_hint": "任意。エンドポイントに `reasoning_effort` として送信されます。許可される値はプロバイダーによって異なります(例: `low`、`high`、`max`)。ここで設定した値が SolidCraft に置き換えられることはありません。空のままなら何も送信されず、プロバイダーの既定値が使われます。", "ui.ai_remove": "削除", "ui.ai_request_timed_out": "モデルの応答に {seconds} 秒以上かかりました。このエンドポイントのリクエストのタイムアウトは、環境設定 › アシスタントで長くできます。", "ui.ai_request_timeout": "リクエストのタイムアウト", "ui.ai_request_timeout_hint": "このエンドポイントへのモデルリクエスト1回にかけられる時間です(30秒〜1時間)。遅いローカルモデルでは長くしてください。", "ui.ai_reset": "リセット", + "ui.ai_save": "保存", "ui.ai_settings": "AI 設定…", "ui.ai_show_features": "AI 機能を表示", "ui.ai_show_features_note": "エージェントチャット: 普段使っているコーディングエージェント、または選んだモデルのエンドポイントが、開いているデザインを操作するパネルです。オフ: AI ボタン、ショートカット、メニュー項目、パネルは表示されず、検索も起動も行いません。", @@ -4108,6 +4428,7 @@ "ui.dark": "ダーク", "ui.default_modelling_orientation": "モデリングのデフォルト方向", "ui.delete": "削除", + "ui.delete_select_first": "先に削除するものを選択してください: フィーチャ、ボディ、コンポーネント、スケッチ ジオメトリ", "ui.depth": "深さ", "ui.design": "デザイン", "ui.design_properties.author": "作成者", @@ -4122,6 +4443,40 @@ "ui.design_properties.project": "プロジェクト", "ui.design_properties.revision": "リビジョン", "ui.design_saved": "{path} に保存しました", + "ui.details_access_caption": "選択した方向から工具が各面に届くかどうかを表示します。", + "ui.details_angle": "角度", + "ui.details_continuity_caption": "選択した継ぎ目を編集します。他の継ぎ目の設定は変更されません。", + "ui.details_continuity_stale": "以前の形状です。この継ぎ目を編集または画面にフィットするには、解析を再実行してください。", + "ui.details_copy_list": "リストをコピー", + "ui.details_curvature": "曲率許容差", + "ui.details_curvature_caption": "面は局所的な曲率半径に応じて色分けされます。", + "ui.details_direction": "工具方向", + "ui.details_draft_caption": "面は抜き方向に対する角度に応じて色分けされます。", + "ui.details_edit_seam": "継ぎ目を編集", + "ui.details_fixture": "UIコンポーネントの表示例 — サンプルデータのみ", + "ui.details_fixture_caption": "説明用の値を用いたUIコンポーネントの表示例です。モデルの解析結果ではありません。", + "ui.details_fixture_problem": "問題を示す検出結果の例", + "ui.details_fixture_readout": "測定値の例", + "ui.details_flip": "反転", + "ui.details_gap": "間隔", + "ui.details_hatch": "切断面をハッチング", + "ui.details_jump": "曲率の不連続", + "ui.details_key": "凡例", + "ui.details_offset": "オフセット", + "ui.details_plane": "平面", + "ui.details_pull": "抜き方向", + "ui.details_radius": "半径", + "ui.details_readout": "測定値", + "ui.details_samples": "サンプル数", + "ui.details_seam": "{body} · エッジ {edge}", + "ui.details_settings": "設定", + "ui.details_sharp_gap": "角が鋭い、または間隔あり", + "ui.details_show_all": "すべて表示({count} 件)", + "ui.details_smooth": "滑らか", + "ui.details_stripes": "縞模様", + "ui.details_target": "形状を画面にフィット", + "ui.details_worst_seams": "連続性の悪い継ぎ目", + "ui.details_zebra_caption": "反射した縞模様で、面のつながりの滑らかさを確認できます。", "ui.diameter": "直径", "ui.dimension": "寸法", "ui.direct_interaction": "ダイレクト操作", @@ -4166,6 +4521,7 @@ "ui.edit_in_place_saved_elsewhere": "部品は別のファイルに保存されました。アセンブリは引き続き {name} にリンクしています", "ui.edit_in_place_wait": "デザインで実行中の処理が終わるまでお待ちください", "ui.edit_section_analysis": "断面解析を編集", + "ui.edit_sketch_empty": "編集できるスケッチはありません", "ui.ellipse": "楕円", "ui.emboss_wrap_note": "サポートされる円柱に接する直交線のプロファイルは自動的に巻き付けられます。対象を選ぶには壁を選択してください。", "ui.end_radius": "終了半径", @@ -4176,12 +4532,15 @@ "ui.equation_explicit": "陽関数", "ui.equation_form": "形式", "ui.equation_note": "変数は単位のない数値です。sin(t) はラジアンとして扱います。単位のない結果はデザインの単位になります。", + "ui.equation_note_range": "変数は範囲と同じく長さです。長さのパラメータと組み合わせられます。sin() には長さで割って渡します。単位のない結果はデザインの単位になります。", "ui.equation_parametric": "パラメトリック", + "ui.equation_variable_unit": "変数に範囲の単位を持たせる", "ui.equation_x_of_t": "x(t)", "ui.equation_y_of_t": "y(t)", "ui.equation_y_of_x": "y(x)", "ui.equivalent_radius": "等価半径", "ui.expand": "展開", + "ui.explode_needs_two_components": "分解図はコンポーネント同士を引き離すため、デザインに 2 つ以上のコンポーネントが必要です(新規コンポーネント、デザインを挿入、またはボディからコンポーネントを作成)", "ui.exportDxf": "DXF をエクスポート…", "ui.export_ellipsis_keymap": "エクスポート…", "ui.export_keymap_tip": "ショートカット、Sボックスのお気に入り、ツールバー設定をファイルに保存", @@ -4350,12 +4709,14 @@ "ui.joints": "ジョイント", "ui.keyboard_shortcuts": "キーボードショートカット", "ui.language": "言語", + "ui.lasso_select": "投げ縄(フリーハンドで囲む:完全に内側にあるものを選択)", "ui.layout_compact": "コンパクト", "ui.layout_floating": "フローティング", "ui.length": "長さ", "ui.length_decimals": "長さの小数点以下の桁数", "ui.light": "ライト", "ui.line": "線分", + "ui.line_arc_hint": "接線円弧: 円弧の終点をクリックします。線に戻るには最後の点で再び止めます。右クリックまたは Esc で終了します", "ui.link_copies": "同一のコピーをリンク", "ui.link_copies_keep": "そのまま", "ui.link_copies_link": "リンク", @@ -4444,6 +4805,7 @@ "ui.part_size_shared": "パーツを再構築し、追加したフィーチャを置き換えます。このコンポーネントのすべての配置が更新されます。", "ui.paste": "貼り付け", "ui.path": "パス", + "ui.pattern_object_type_tip": "ボディ: ビューまたはブラウザで選ぶ立体形状。それぞれがまるごとコピーされます。\nフィーチャー: 押し出しや穴など履歴のステップ。履歴で、またはそれが作った面で選びます。そのステップが繰り返されます。\nスケッチはどちらでもありません: 曲線はスケッチ内のパターンかミラーで繰り返すか、先に押し出してください。", "ui.pattern_on_path": "パス上のパターン", "ui.perforate_margin_default": "直径と同じ", "ui.perforate_pitch": "ピッチ", @@ -4509,6 +4871,7 @@ "ui.properties.inertia_y": "Iyx, Iyy, Iyz", "ui.properties.inertia_z": "Izx, Izy, Izz", "ui.properties.measuring": "測定中…", + "ui.properties.measuring_bodies": "測定中… {total} 個中 {done} 個のボディ", "ui.properties.measuring_failed": "測定が完了する前に停止しました。", "ui.properties.principal_axis": "主軸 {number}", "ui.properties.principal_moments": "主慣性モーメント", diff --git a/assets/locales/ko.json b/assets/locales/ko.json index ec3be2dfaf..d8dbd9494d 100644 --- a/assets/locales/ko.json +++ b/assets/locales/ko.json @@ -711,6 +711,7 @@ "cmd.appearance_pick_up": "모양 가져오기", "cmd.appearance_replace": "모양 바꾸기", "cmd.assembly_bom": "자재 명세서", + "cmd.assembly_mass_properties": "질량 특성", "cmd.body.print_face": "출력 바닥면", "cmd.body_rename": "바디 이름 변경", "cmd.browser_group": "새 그룹", @@ -1094,6 +1095,7 @@ "cmd.view.render_browser": "렌더 브라우저", "cmd.view.render_dock": "렌더 도크 이동", "cmd.view.render_focus": "렌더 집중 보기", + "cmd.view.status_details": "뷰포트 상태 상세 정보", "cmd.view.status_items": "뷰포트 상태 항목", "cmd.view_ambient_occlusion": "앰비언트 오클루전", "cmd.view_angle_readout": "뷰 각도", @@ -1161,6 +1163,7 @@ "dialog.dimension_insert_hint": "다른 구동 치수를 두 번 클릭하여 이름 삽입", "dialog.dimension_name": "치수: {name}", "dialog.edit_title": "{title} 편집", + "dialog.pattern_sketch_not_object": "바디나 피처를 선택하세요. 스케치는 둘 다 아닙니다. 곡선은 스케치 안의 패턴이나 대칭 복사로 반복하거나, 먼저 돌출하세요.", "dialog.select": "↖ 선택", "dialog.selected_count": "↖ {count}개 선택됨", "dialog.selection_guidance": "변경할 입력을 선택하세요. 선택한 객체를 클릭하면 선택이 해제되며, Ctrl/Cmd를 누른 채 클릭해도 전환됩니다. 미리 보기를 확인한 다음 확인을 누르세요.", @@ -1544,6 +1547,8 @@ "prefs.ctrl_cmd": "Ctrl / Cmd", "prefs.curved_surfaces": "곡면", "prefs.curves": "곡면", + "prefs.dark_parts_shading": "어두운 부품의 형태 유지", + "prefs.dark_parts_shading_help": "선형 조명과 부드러운 하늘광으로 뷰를 음영 처리하여 어두운 면이 검게 뭉개지지 않고 그라데이션을 유지합니다", "prefs.dead_zone_note": "빗금 친 영역이 데드존입니다. 그 안의 움직임은 무시됩니다.", "prefs.default": "기본값", "prefs.design": "설계 기본값", @@ -1579,6 +1584,8 @@ "prefs.history_play_button": "기록 컨트롤에 기록 재생 버튼 표시", "prefs.history_play_button_note": "기록 컨트롤의 한 단계 뒤로와 한 단계 앞으로 사이에 기록 재생을 추가합니다. 컨트롤을 마우스 오른쪽 버튼으로 클릭해 표시하거나 숨길 수도 있습니다.", "prefs.hold_stop_snapping": "누르고 있으면 스냅 중지", + "prefs.home_fit_size": "홈·맞춤 크기", + "prefs.home_fit_size_note": "홈, 맞춤, 연 디자인 또는 뷰 탐색기의 뷰 변경으로 모델을 화면에 맞출 때 모델이 뷰에서 차지하는 비율. 아래 설정과 관계없이 적용됩니다. 100 %: 패널, 나침반, 내비게이션 바 사이의 뷰를 가득 채웁니다.", "prefs.icons_only": "아이콘만", "prefs.imported_file": "가져온 파일", "prefs.input": "입력", @@ -1587,12 +1594,15 @@ "prefs.interface_note": "SolidCraft의 모양과 각 부분의 위치입니다.", "prefs.interval_minutes": "{n}분", "prefs.labels": "레이블", + "prefs.language_system": "시스템", "prefs.layout": "레이아웃", "prefs.layout_note": "컴팩트는 패널을 창 가장자리에 고정합니다. 브라우저 반투명은 플로팅에서 사용할 수 있습니다. 어시스턴트 위치는 어시스턴트 페이지에서 바꿀 수 있습니다.", "prefs.left": "왼쪽", "prefs.left_click": "왼쪽 클릭", "prefs.line_drag_draws_one": "누른 채 드래그하면 선 하나 그리기", "prefs.line_drag_draws_one_note": "선 도구에서 누르고 드래그한 뒤 놓으면 누른 곳에서 놓은 곳까지 선 하나를 그리고 거기서 끝납니다. 클릭하면 이전처럼 이어지는 선을 그립니다. 끄면: 드래그는 클릭처럼 놓은 곳에 점을 둡니다.", + "prefs.line_hover_tangent_arc": "선의 마지막 점에 머무르면 접선 호로 전환", + "prefs.line_hover_tangent_arc_note": "선 도구에서 선분을 그린 뒤 체인의 마지막 점에서 벗어났다가 돌아와 포인터를 그 위에 멈추면, 다음 클릭으로 접선 호의 끝점을 지정합니다. 같은 동작으로 선으로 돌아갑니다. 끔: 머물러도 아무 일도 일어나지 않습니다.", "prefs.live_input": "실시간 입력", "prefs.live_preview": "실시간 미리보기", "prefs.max_file_size": "열 수 있는 최대 파일 크기", @@ -1609,6 +1619,8 @@ "prefs.mouse_mapping": "마우스 매핑", "prefs.navigation": "탐색", "prefs.navigation_note": "마우스, 트랙패드, 3D 마우스로 뷰를 움직이는 방식입니다.", + "prefs.navigator_fit": "뷰 탐색기로 뷰를 바꾼 뒤 모델 맞춤", + "prefs.navigator_fit_note": "큐브의 면, 모서리, 화살표나 기즈모의 축을 클릭하면 어느 방향에서든 전체가 들어가는 한 거리(경계 구)에서 모델의 중심을 바라봅니다. 켜져 있는 동안 홈 뷰도 같은 확대/축소와 중심을 쓰므로 그 뒤에 고른 뷰는 방향만 바뀝니다. 끄면 뷰가 제자리에서 회전하며 대상과 확대/축소를 유지합니다.", "prefs.not_on_gpu": "이 GPU에서는 사용할 수 없음", "prefs.on": "켜짐", "prefs.opaque": "불투명", @@ -1630,6 +1642,8 @@ "prefs.quality_slowest": "가장 느림", "prefs.quality_smoother": "더 부드럽게", "prefs.reports_saved": "{n}개 저장됨", + "prefs.ribbon_click_ends_tool": "실행 중인 도구의 버튼을 클릭하면 도구 종료", + "prefs.ribbon_click_ends_tool_note": "사용 중인 스케치 도구의 강조된 리본 버튼을 클릭하면 도구가 종료되어 뷰에서 클릭하면 다시 선택됩니다. 끄기: 클릭하면 도구가 다시 시작됩니다.", "prefs.right_click": "오른쪽 클릭", "prefs.saving": "자동 저장 및 복구", "prefs.scale_note": "창에서 Ctrl + / Ctrl −로도 조절합니다. 버튼은 100%에서 28 px입니다.", @@ -1665,6 +1679,8 @@ "prefs.sketch_tab_always_note": "스케치가 열려 있지 않을 때도 스케치 탭이 리본에서 모델 바로 다음에 남아 있습니다. 그 그리기 도구는 선택한 평면이나 면, 또는 다음에 고르는 평면에서 스케치를 시작하고 그 안에서 도구를 바로 쓸 수 있게 합니다.", "prefs.sketch_tracking_guides": "중심 및 중점 정렬 안내선", "prefs.sketch_tracking_guides_note": "원 중심이나 직선 모서리의 중점에 마우스를 올린 다음 수평 또는 수직으로 이동하면 임시 점선 안내선이 표시됩니다.", + "prefs.sketch_tracking_points": "그리는 동안 끝점과 모서리에서도 정렬 안내선", + "prefs.sketch_tracking_points_note": "그리기 도구가 점을 기다리는 동안 선이나 호의 끝점, 직사각형 모서리, 자유 점에 마우스를 올리면 중심이나 중점과 같은 임시 점선 안내선이 표시됩니다. 끄면: 중심과 중점만.", "prefs.sketching": "스케치", "prefs.snap_drawing_note": "스케치 점을 그리거나 드래그하는 동안.", "prefs.source": "소스", @@ -1676,6 +1692,24 @@ "prefs.status_error": "오류", "prefs.status_selected": "선택됨", "prefs.status_warning": "경고", + "prefs.structure.appearance": "모양", + "prefs.structure.appearance_note": "색상, 텍스트 및 접근성.", + "prefs.structure.in_profile": "프로필에 포함", + "prefs.structure.layout": "레이아웃", + "prefs.structure.layout_note": "창 구성 요소의 위치.", + "prefs.structure.look": "보기", + "prefs.structure.measure_pointer": "측정 및 포인터", + "prefs.structure.mouse3d": "3D 마우스", + "prefs.structure.mouse3d_note": "SpaceMouse 및 SpaceNavigator.", + "prefs.structure.navigation_note": "마우스와 트랙패드. 3D 마우스는 별도 페이지에 있습니다.", + "prefs.structure.profile": "프로필", + "prefs.structure.profile_edited": "저장 후 변경됨", + "prefs.structure.profile_hover": "설정 프로필에 저장됨", + "prefs.structure.scale_percent": "{percent}%", + "prefs.structure.sketch": "스케치", + "prefs.structure.tools": "도구", + "prefs.structure.tools_note": "모델링 도구의 동작.", + "prefs.structure.work": "작업", "prefs.supersampling_brief": "뷰를 더 크게 그린 뒤 줄여서 필터링합니다. 매우 선명하지만 더 무겁습니다.", "prefs.tab_accent_note": "강조 색상이 활성 탭을 따라갑니다", "prefs.theme_file": "테마 파일", @@ -1685,6 +1719,16 @@ "prefs.theme_follow_note": "다른 도구가 파일을 다시 쓸 때마다 다시 불러옵니다. 테마를 선택하면 따르기를 중지합니다.", "prefs.theme_follow_off": "끄기", "prefs.theme_follow_stop": "따르기 중지", + "prefs.theme_option_opacity": "불투명도", + "prefs.theme_option_opacity_help": "플로팅 패널의 불투명도를 조정합니다. 뷰포트 색상 위에서도 텍스트를 읽을 수 있도록 최솟값이 적용됩니다.", + "prefs.theme_option_tab_colours": "탭 색상", + "prefs.theme_option_tab_colours_help": "각 리본 탭에 이 테마의 패널 및 뷰포트 색상을 사용합니다.", + "prefs.theme_option_tab_fade_ms": "탭 전환", + "prefs.theme_option_tab_fade_ms_help": "탭을 전환할 때 패널 및 뷰포트 색상을 서서히 변경합니다. 0이면 즉시 전환합니다.", + "prefs.theme_options_badge": "옵션", + "prefs.theme_options_more": "더 보기 · {count}", + "prefs.theme_options_reset": "초기화", + "prefs.theme_options_unavailable": "이 기기에서는 사용할 수 없음", "prefs.timeline_half_hour": "−30분", "prefs.timeline_now": "지금", "prefs.timeline_quarter_hour": "−15", @@ -1714,6 +1758,8 @@ "prefs.viewport_analysis_status_help": "활성 해석을 뷰포트 위에 표시하고 각 항목에서 범례를 엽니다. Esc로 최신 해석을 끕니다. 끄면 모서리 범례와 이전 Esc 동작으로 돌아갑니다.", "prefs.viewport_section_status": "뷰포트 단면 상태 표시", "prefs.viewport_section_status_help": "뷰포트에 단면 설정을 표시합니다. Escape 키와 활성 리본 버튼으로 단면을 끕니다. 비활성화하면 이전 단면 조작을 유지합니다.", + "prefs.viewport_status_details": "뷰포트 실시간 상세 정보", + "prefs.viewport_status_details_help": "표준 상세 카드에서 활성 분석 및 단면 설정을 편집합니다. 끄면 읽기 전용 분석 범례와 단면 대화상자로 돌아갑니다.", "prefs.viewport_status_summary": "뷰포트 상태 항목 요약", "prefs.viewport_status_summary_help": "상태 항목이 3개 이상이면 하나의 요약 배지로 표시합니다. 비활성화하면 모든 항목을 기존 행에 표시합니다.", "prefs.wheel": "휠", @@ -2876,6 +2922,7 @@ "ui.agent_chat_auth_terminal": "터미널에서 이것을 실행한 뒤 다시 연결을 누르세요:", "ui.agent_chat_auth_title": "에이전트에 로그인", "ui.agent_chat_cancel": "취소", + "ui.agent_chat_cannot_start": "설치되어 있지만 시작되지 않습니다: {error}", "ui.agent_chat_changes": "변경 {n}개, 실행 취소 한 번", "ui.agent_chat_changes_of": "변경 {n}/{limit}", "ui.agent_chat_close": "닫기", @@ -2952,6 +2999,7 @@ "ui.agent_chat_queued_stopped": "대기 중 (중지됨, 보내지 않음)", "ui.agent_chat_refresh": "새로 고침", "ui.agent_chat_refused": "에이전트에게 허용되지 않음: {reason}", + "ui.agent_chat_reinstall": "{agent}이(가) 설치되어 있지만({program}) SolidCraft가 확인할 때 시작되지 않았습니다: {error}\n설치가 완료되지 않았거나 다른 종류의 컴퓨터용일 수 있습니다. `{install}`(으)로 다시 설치하고, 터미널에서 버전이 출력되는지 확인한 뒤 새로 고침을 누르세요.", "ui.agent_chat_remove": "대기열에서 제거", "ui.agent_chat_remove_file": "이 메시지에서 빼기", "ui.agent_chat_remove_imported": "이 메시지에서 빼기. 부품은 디자인에 남습니다 (실행 취소하면 제거됨)", @@ -3010,12 +3058,17 @@ "ui.ai_copy_suffix": "복사본", "ui.ai_custom_url": "사용자 지정 URL", "ui.ai_default": "기본값", + "ui.ai_edit": "편집", + "ui.ai_edit_agent_title": "에이전트 편집", + "ui.ai_edit_gone": "이 에이전트는 제거되었으므로 저장할 내용이 없습니다.", + "ui.ai_edit_tip": "이 에이전트의 이름, 명령 또는 엔드포인트를 변경합니다. 저장된 키는 표시되지 않습니다.", "ui.ai_http_busy": "엔드포인트가 바쁘거나 한도에 도달했습니다. 나중에 다시 시도하세요", "ui.ai_http_key": "엔드포인트가 키를 거부했습니다. 환경 설정 > AI에서 확인하세요", "ui.ai_http_not_found": "엔드포인트나 모델을 찾을 수 없습니다. 기본 URL과 모델 이름을 확인하세요", "ui.ai_http_too_large": "대화가 이 엔드포인트에 비해 너무 큽니다. 새 채팅을 시작하세요", "ui.ai_key": "API 키", "ui.ai_key_hint": "선택 사항, 시스템 키체인에 저장", + "ui.ai_key_keep_hint": "저장된 키를 유지하려면 비워 두세요", "ui.ai_key_needs_https": "키는 HTTPS를 통해서만, 또는 이 컴퓨터로만 전송됩니다.", "ui.ai_key_note": "SolidCraft 설정이 아닌 시스템 키체인에 저장됩니다. 로컬 서버라면 비워 두세요.", "ui.ai_keychain_error": "시스템 키체인이 거부했습니다: {error}", @@ -3042,6 +3095,7 @@ "ui.ai_preset": "프리셋", "ui.ai_remove": "제거", "ui.ai_reset": "재설정", + "ui.ai_save": "저장", "ui.ai_settings": "AI 설정…", "ui.ai_show_features": "AI 기능 표시", "ui.ai_show_features_note": "에이전트 채팅: 이미 사용 중인 코딩 에이전트나 선택한 모델 엔드포인트가 열린 디자인을 다루는 패널입니다. 끄면 AI 버튼, 단축키, 메뉴 항목, 패널이 없고 아무것도 찾거나 시작하지 않습니다.", @@ -3174,6 +3228,7 @@ "ui.dark": "어둡게", "ui.default_modelling_orientation": "기본 모델링 방향", "ui.delete": "삭제", + "ui.delete_select_first": "먼저 삭제할 항목을 선택하세요: 피처, 바디, 컴포넌트 또는 스케치 형상", "ui.depth": "깊이", "ui.design": "디자인", "ui.design_properties.author": "작성자", @@ -3187,6 +3242,40 @@ "ui.design_properties.part_number": "부품 번호", "ui.design_properties.project": "프로젝트", "ui.design_properties.revision": "리비전", + "ui.details_access_caption": "선택한 방향에서 공구가 각 면에 도달할 수 있는지 표시합니다.", + "ui.details_angle": "각도", + "ui.details_continuity_caption": "선택한 이음부를 편집합니다. 다른 이음부는 각자의 설정을 유지합니다.", + "ui.details_continuity_stale": "이전 형상입니다. 이 이음부를 편집하거나 화면에 맞추려면 분석을 다시 실행하세요.", + "ui.details_copy_list": "목록 복사", + "ui.details_curvature": "곡률 공차", + "ui.details_curvature_caption": "각 면의 국소 곡률 반지름에 따라 색상이 표시됩니다.", + "ui.details_direction": "공구 방향", + "ui.details_draft_caption": "금형 분리 방향에 대한 각 면의 각도에 따라 색상이 표시됩니다.", + "ui.details_edit_seam": "이음부 편집", + "ui.details_fixture": "UI 구성 요소 예시 — 샘플 데이터만 표시", + "ui.details_fixture_caption": "설명용 값을 사용하는 UI 구성 요소 예시입니다. 모델의 분석 결과가 아닙니다.", + "ui.details_fixture_problem": "문제가 있는 결과 예시", + "ui.details_fixture_readout": "측정값 예시", + "ui.details_flip": "뒤집기", + "ui.details_gap": "간격", + "ui.details_hatch": "절단면 해칭", + "ui.details_jump": "곡률 불연속", + "ui.details_key": "범례", + "ui.details_offset": "오프셋", + "ui.details_plane": "평면", + "ui.details_pull": "금형 분리 방향", + "ui.details_radius": "반지름", + "ui.details_readout": "측정값", + "ui.details_samples": "샘플 수", + "ui.details_seam": "{body} · 모서리 {edge}", + "ui.details_settings": "설정", + "ui.details_sharp_gap": "날카로운 이음 또는 간격", + "ui.details_show_all": "전체 {count}개 표시", + "ui.details_smooth": "매끄러움", + "ui.details_stripes": "줄무늬", + "ui.details_target": "형상에 맞게 보기 조정", + "ui.details_worst_seams": "연속성이 가장 나쁜 이음부", + "ui.details_zebra_caption": "반사된 줄무늬는 면이 얼마나 매끄럽게 이어지는지 보여 줍니다.", "ui.diameter": "지름", "ui.dimension": "치수", "ui.direct_interaction": "직접 조작", @@ -3231,6 +3320,7 @@ "ui.edit_in_place_saved_elsewhere": "부품이 다른 파일에 저장되었습니다. 어셈블리는 여전히 {name}에 연결되어 있습니다", "ui.edit_in_place_wait": "디자인에서 실행 중인 작업이 끝날 때까지 기다리세요", "ui.edit_section_analysis": "단면 분석 편집", + "ui.edit_sketch_empty": "편집할 스케치가 없습니다", "ui.ellipse": "타원", "ui.emboss_wrap_note": "지원되는 원통에 접하는 직교 선 프로파일은 자동으로 감깁니다. 대상을 고르려면 벽을 선택하세요.", "ui.end_radius": "끝 반지름", @@ -3241,12 +3331,15 @@ "ui.equation_explicit": "명시적", "ui.equation_form": "형식", "ui.equation_note": "변수는 단위가 없는 수입니다. sin(t)는 라디안으로 읽습니다. 단위가 없는 결과는 디자인 단위입니다.", + "ui.equation_note_range": "변수는 범위와 같이 길이입니다. 길이 매개변수와 함께 쓸 수 있으며, sin()에는 길이로 나누어 넣습니다. 단위가 없는 결과는 디자인 단위로 읽습니다.", "ui.equation_parametric": "매개변수식", + "ui.equation_variable_unit": "변수에 범위의 단위 사용", "ui.equation_x_of_t": "x(t)", "ui.equation_y_of_t": "y(t)", "ui.equation_y_of_x": "y(x)", "ui.equivalent_radius": "등가 반지름", "ui.expand": "펼치기", + "ui.explode_needs_two_components": "분해 보기는 구성 요소를 서로 떼어 놓으므로 설계에 구성 요소가 두 개 이상 있어야 합니다(새 구성 요소, 설계 삽입 또는 바디로 구성 요소 만들기)", "ui.exportDxf": "DXF 내보내기…", "ui.export_ellipsis_keymap": "내보내기…", "ui.export_keymap_tip": "키, S 상자 즐겨찾기, 도구 모음을 파일로 내보내기", @@ -3404,12 +3497,14 @@ "ui.joints": "조인트", "ui.keyboard_shortcuts": "키보드 단축키", "ui.language": "언어", + "ui.lasso_select": "올가미(자유롭게 둘러 그리기: 완전히 안쪽에 있는 것을 선택)", "ui.layout_compact": "컴팩트", "ui.layout_floating": "플로팅", "ui.length": "길이", "ui.length_decimals": "길이 소수점 자릿수", "ui.light": "밝게", "ui.line": "선", + "ui.line_arc_hint": "접선 호: 호의 끝점을 클릭합니다. 선으로 돌아가려면 마지막 점에 다시 머무르고, 끝내려면 오른쪽 클릭 또는 Esc를 누릅니다", "ui.lock": "잠금", "ui.loft": "로프트", "ui.look_at": "바라보기", @@ -3489,6 +3584,7 @@ "ui.part_size_shared": "부품을 다시 빌드하며 추가된 피처는 모두 대체됩니다. 이 구성요소의 모든 인스턴스가 업데이트됩니다.", "ui.paste": "붙여넣기", "ui.path": "경로", + "ui.pattern_object_type_tip": "바디: 뷰나 브라우저에서 선택하는 솔리드 형상이며, 각각 통째로 복사됩니다.\n피처: 돌출이나 구멍 같은 기록의 단계이며, 기록에서 또는 그 단계가 만든 면에서 선택합니다. 그 단계가 반복됩니다.\n스케치는 둘 다 아닙니다. 곡선은 스케치 안의 패턴이나 대칭 복사로 반복하거나, 먼저 돌출하세요.", "ui.pattern_on_path": "경로 패턴", "ui.perpendicular": "수직", "ui.perspective_view": "원근 투영 뷰", @@ -3540,6 +3636,7 @@ "ui.properties.inertia_y": "Iyx, Iyy, Iyz", "ui.properties.inertia_z": "Izx, Izy, Izz", "ui.properties.measuring": "측정 중…", + "ui.properties.measuring_bodies": "측정 중… 바디 {total}개 중 {done}개", "ui.properties.measuring_failed": "측정이 끝나기 전에 중단되었습니다.", "ui.properties.principal_axis": "주축 {number}", "ui.properties.principal_moments": "주관성 모멘트", diff --git a/assets/locales/ms.json b/assets/locales/ms.json index c201d5aa50..43aa271c97 100644 --- a/assets/locales/ms.json +++ b/assets/locales/ms.json @@ -60,6 +60,7 @@ "BLEND SURFACE": "PERMUKAAN GAUNAN", "BOUNDARY SURFACE": "PERMUKAAN SEMPADAN", "BROWSER": "PELAYAR", + "Back to the original order": "Kembali ke susunan asal", "Ball": "Bebola", "Bar": "Bar", "Base Center": "Pusat Tapak", @@ -595,6 +596,8 @@ "Snap to face and edge centres": "Lekap pada pusat muka dan tepi", "SolidCraft cannot submit reports directly. Submit on GitHub; no token is needed in the app.": "SolidCraft tidak dapat menghantar laporan secara terus. Hantar di GitHub; tiada token diperlukan dalam aplikasi.", "SolidCraft closed before these designs were saved. Their last autosave can be opened.": "SolidCraft ditutup sebelum reka bentuk disimpan. Buka simpanan automatik terakhirnya.", + "Sort A to Z": "Isih A hingga Z", + "Sort Z to A": "Isih Z hingga A", "Spacing 2 must be a non-zero number": "Selang 2 mestilah nombor bukan sifar", "Spacing must be a non-zero number": "Selang mestilah nombor bukan sifar", "Specialise": "Khususkan", @@ -819,6 +822,7 @@ "cmd.appearance_pick_up": "Ambil Penampilan", "cmd.appearance_replace": "Gantikan Penampilan", "cmd.assembly_bom": "Senarai Bahan", + "cmd.assembly_mass_properties": "Sifat jisim", "cmd.body.print_face": "Muka cetakan", "cmd.body_rename": "Tukar Nama Jasad", "cmd.browser_collapse_all": "Ringkaskan Semua", @@ -1244,6 +1248,7 @@ "cmd.view.render_browser": "Pelayar Render", "cmd.view.render_dock": "Alih Dok Render", "cmd.view.render_focus": "Pandangan Fokus Render", + "cmd.view.status_details": "Butiran Status Tetingkap Pandangan", "cmd.view.status_items": "Item Status Tetingkap Pandangan", "cmd.view_ambient_occlusion": "Oklusi Ambien", "cmd.view_angle_readout": "Sudut Pandangan", @@ -1387,21 +1392,168 @@ "from the rule": "daripada peraturan", "help.apache": "Lesen Apache 2.0", "help.appearance.assign": "Memberikan warna atau rupa bahan kepada jasad, muka atau komponen yang dipilih, tanpa mengubah beratnya. Gunakan untuk membezakan bahagian atau melihat pratonton kemasan.", + "help.assembly.bom": "Menyenaraikan bahagian reka bentuk atau komponen beserta kuantitinya, mengikut nombor bahagian, dan boleh menyimpan senarai itu sebagai CSV atau JSON. Gunakan sebagai senarai bahagian untuk pesanan dan pemasangan.", "help.attributions": "Atribusi (ATTRIBUTION.md)", "help.build": "Versi {version} · commit {commit} · dibina {date}", + "help.canvas.decal": "Meletakkan imej pada muka rata model, seperti pelekat. Gunakan untuk memaparkan label, logo atau corak bercetak pada sesuatu bahagian.", + "help.canvas.edit": "Mengubah kanvas: kedudukan, saiz, sudut, kelegapan, pencerminan, keterlihatan atau namanya. Gunakan untuk menjajarkan imej rujukan dengan lakaran yang anda surih.", + "help.canvas.insert": "Meletakkan imej PNG atau JPEG pada satah atau muka sebagai rujukan lut sinar. Gunakan untuk menyurih foto atau lukisan dalam lakaran, dengan imej berskala pada saiz yang diketahui.", + "help.component.break_link": "Memutuskan komponen terpaut daripada fail reka bentuknya dan mengekalkan jasadnya seperti sedia ada. Gunakan apabila bahagian itu tidak lagi perlu mengikut perubahan pada fail asal.", + "help.component.capture_position": "Menyimpan kedudukan bahagian yang anda alihkan dengan tangan, supaya kedudukan itu menjadi tempat bahagian tersebut. Gunakan selepas menyeret bahagian ke tempatnya yang tidak dipegang oleh sendi.", + "help.component.create": "Menambah komponen baharu yang kosong ke dalam reka bentuk, di dalam komponen aktif melainkan anda memilih induk lain. Gunakan untuk memberi bahagian baharu pemasangan tempatnya sendiri bagi jasad dan lakaran.", "help.component.finish_edit": "Menyimpan bahagian yang sedang anda sunting di tempat, kembali ke pemasangannya dan mengemas kini pemasangan daripadanya. Gunakannya apabila perubahan anda pada bahagian terpaut sudah selesai.", + "help.component.from_bodies": "Menjadikan setiap jasad yang dipilih sebagai komponen tersendiri, dinamakan sempena jasad itu. Gunakan apabila reka bentuk yang dibina daripada jasad berasingan patut menjadi pemasangan yang bahagiannya boleh disambung dan digerakkan.", + "help.component.insert_design": "Menyisip reka bentuk tersimpan lain sebagai komponen yang dipautkan ke failnya, dengan jasad pepejalnya diletakkan seperti dalam fail itu. Gunakan untuk menggunakan semula bahagian dalam pemasangan dan menerima perubahan kemudian dengan Kemas Kini Pautan.", + "help.component.properties": "Memaparkan isi padu, jisim, pusat jisim dan momen inersia sesebuah komponen, menggunakan bahan jasadnya. Gunakan untuk menyemak berat dan keseimbangan pemasangan.", + "help.component.revert_position": "Mengembalikan bahagian yang anda alihkan dengan tangan ke tempat asalnya, dengan membuang pergerakan yang tidak dirakam. Gunakan untuk membuat asal pergerakan percubaan dalam pemasangan.", + "help.component.update_links": "Membaca semula fail reka bentuk terpaut dan menggantikan jasad yang telah berubah. Gunakan selepas menyunting bahagian yang digunakan oleh pemasangan ini.", + "help.config.configure": "Menambah parameter, ciri, bahan atau penampilan sebagai lajur jadual konfigurasi. Gunakan untuk membina varian satu reka bentuk, seperti pelbagai saiz sesuatu bahagian.", + "help.config.preview": "Menyemak bahawa satu baris jadual konfigurasi dapat dibina semula, tanpa mengubah reka bentuk atau sejarahnya. Gunakan sebelum bertukar kepada varian yang anda tidak pasti.", + "help.config.rules": "Memaparkan peraturan jadual konfigurasi, barisnya dan baris yang aktif. Gunakan untuk melihat cara varian sesuatu reka bentuk disediakan.", + "help.config.table": "Memaparkan jadual konfigurasi: satu baris bagi setiap varian, satu lajur bagi setiap parameter atau ciri, dan sama ada reka bentuk masih sepadan dengan baris aktif. Gunakan untuk menyemak varian sesuatu reka bentuk.", "help.connect_note": "Mulakan semula setiap pembantu untuk memuatkan pelayan SolidCraft. Setiap fail telah disandarkan sebagai .bak sebelum diubah.", "help.connect_title": "Sambungkan pembantu AI", + "help.construct.axis.edge": "Mencipta paksi binaan sepanjang tepi lurus atau garis lakaran. Gunakan sebagai paksi untuk Putar, corak membulat atau sendi.", + "help.construct.axis.normal_to_face": "Mencipta paksi binaan melalui satu titik, serenjang dengan muka. Gunakan sebagai paksi yang tegak keluar dari permukaan.", + "help.construct.axis.through_cylinder": "Mencipta paksi binaan melalui pusat muka silinder, kon atau torus, atau tepi bulat. Gunakan untuk memutar atau mencorak ciri di sekeliling bahagian bulat.", + "help.construct.axis.two_planes": "Mencipta paksi binaan di tempat dua satah atau muka rata bertemu. Gunakan apabila paksi yang diperlukan ialah garis antara dua muka.", + "help.construct.axis.two_points": "Mencipta paksi binaan melalui dua titik, bucu atau titik lakaran. Gunakan untuk paksi yang menghubungkan dua kedudukan yang diketahui.", + "help.construct.plane.along_path": "Mencipta satah binaan yang serenjang dengan tepi atau lengkung, pada satu kedudukan sepanjangnya. Gunakan untuk melakar profil bagi Sapu atau keratan sesuatu laluan.", + "help.construct.plane.at_angle": "Mencipta satah binaan yang dipusingkan pada satu sudut daripada satah atau muka, pada paksi atau tepi. Gunakan untuk lakaran condong, seperti braket bersudut.", + "help.construct.plane.midplane": "Mencipta satah binaan di tengah-tengah antara dua satah atau muka rata, atau yang membahagi dua kedua-duanya di tempat ia bertemu pada satu sudut. Gunakan sebagai satah simetri untuk pencerminan.", + "help.construct.plane.offset": "Mencipta satah binaan yang selari dengan satah atau muka rata, pada satu jarak atau melalui satu titik. Gunakan untuk melakar pada ketinggian tertentu di atas sesuatu muka.", + "help.construct.plane.perpendicular": "Mencipta satah binaan yang memuatkan garis atau tepi dan berdiri serenjang dengan satah atau muka. Gunakan untuk melakar secara tegak pada model sepanjang tepi.", + "help.construct.plane.tangent": "Mencipta satah binaan yang menyentuh silinder, kon, sfera atau torus pada satu titik. Gunakan untuk melakar pada sisi bahagian bulat.", + "help.construct.plane.three_points": "Mencipta satah binaan melalui tiga titik. Gunakan untuk satah yang ditentukan oleh kedudukan pada model, seperti tiga bucu.", + "help.construct.plane.two_edges": "Mencipta satah binaan melalui dua tepi atau garis lurus yang terletak pada satu satah. Gunakan untuk satah merentasi sesuatu bahagian, seperti melalui dua tepi yang bertentangan.", + "help.construct.point.along_path": "Mencipta titik binaan pada satu kedudukan sepanjang tepi atau lengkung. Gunakan sebagai rujukan di pertengahan laluan.", + "help.construct.point.center": "Mencipta titik binaan di pusat tepi bulat, bulatan atau lengkok lakaran, sfera atau torus. Gunakan sebagai rujukan untuk lubang, paksi dan sendi.", + "help.construct.point.edge_plane": "Mencipta titik binaan di tempat tepi atau garis melalui satah atau muka rata. Gunakan untuk menandakan tempat laluan merentasi satah.", + "help.construct.point.three_planes": "Mencipta titik binaan di tempat tiga satah atau muka rata bertemu. Gunakan sebagai rujukan sudut, walaupun model tiada bucu di situ.", + "help.construct.point.two_edges": "Mencipta titik binaan di tempat dua tepi atau garis bertemu, atau paling hampir antara satu sama lain. Gunakan sebagai rujukan pada sudut maya.", + "help.construct.point.vertex": "Mencipta titik binaan pada bucu atau titik lakaran. Gunakan sebagai rujukan yang kekal apabila lakaran atau jasad di sekelilingnya disembunyikan.", + "help.contact.check": "Menyenaraikan pasangan bahagian dalam set sentuhan yang menembusi satu sama lain pada kedudukan semasa. Gunakan untuk mencari perlanggaran sebelum anda menggerakkan sendi atau menjalankan kajian gerakan.", + "help.contact.create": "Mengumpulkan dua atau lebih bahagian ke dalam satu set sentuhan, yang jasadnya dihalang daripada menembusi satu sama lain semasa sendi bergerak. Gunakan untuk bahagian yang patut berhenti apabila bersentuhan, seperti tuil terhadap penahannya.", + "help.contact.disable_all": "Mematikan sentuhan, supaya sendi yang digerakkan dan kajian gerakan tidak lagi berhenti di tempat bahagian bersentuhan. Gunakan untuk menggerakkan bahagian dengan bebas semula, atau untuk memastikan pemasangan besar kekal responsif.", + "help.contact.edit": "Mengubah set sentuhan: bahagian yang dipegangnya, namanya, atau sama ada ia dinyahaktifkan; ia juga boleh memadam set itu. Gunakan untuk melaraskan bahagian mana yang dihalang daripada menembusi satu sama lain.", + "help.contact.enable_all": "Memasukkan setiap bahagian pemasangan ke dalam satu set sentuhan, supaya tiada dua bahagian dapat menembusi satu sama lain semasa sendi bergerak. Gunakan untuk semakan pantas seluruh mekanisme.", + "help.contact.enable_sets": "Mengaktifkan sentuhan bagi set sentuhan yang anda takrifkan, supaya sendi yang digerakkan dan kajian gerakan berhenti di tempat bahagiannya akan menembusi satu sama lain. Gunakan untuk memulihkan set anda selepas mematikan sentuhan.", + "help.contact.list": "Menyenaraikan set sentuhan dalam reka bentuk, bahagian dalam setiap satu dan sama ada sentuhan diaktifkan. Gunakan untuk menyemak cara sentuhan disediakan sebelum anda menggerakkan pemasangan.", + "help.document.inspect": "Memaparkan gambaran keseluruhan reka bentuk: komponen, jasad, lakaran, ciri dan parameternya. Gunakan untuk memahami reka bentuk yang bukan anda bina sendiri.", + "help.document.units": "Menukar unit yang digunakan reka bentuk untuk memaparkan panjang, seperti mm atau inci. Geometri kekal bersaiz sama; hanya paparan dan nombor biasa yang mengikutinya.", + "help.edit.checkpoint": "Mengingati reka bentuk, lakaran aktif dan sejarah Buat Asal seperti keadaan sekarang. Gunakan sebelum mencuba sesuatu yang berisiko dan mungkin hendak anda kembali daripadanya.", + "help.edit.restore_checkpoint": "Kembali ke titik semak, termasuk sejarah Buat Asal. Gunakan untuk membuang semua yang dilakukan sejak titik semak dalam satu langkah.", + "help.explode.create": "Mencipta pandangan terlerai yang menolak bahagian menjauhi pusat pemasangan, atau mengikut pergerakan yang anda tetapkan. Gunakan untuk menunjukkan cara pemasangan disatukan, bagi arahan atau pembentangan.", "help.face.delete": "Membuang muka yang dipilih dan membiarkan muka bersebelahan menutup ruang kosong itu, atau membiarkannya terbuka sebagai permukaan. Gunakan untuk menanggalkan tepi bulat, lubang atau bonggol yang menghalang.", + "help.file.insert_mesh": "Menambah fail 3MF, STL, OBJ, PLY atau glTF ke dalam reka bentuk sebagai jasad mesh dalam komponen baharu. Gunakan untuk meletakkan model yang dimuat turun atau diimbas di sebelah reka bentuk anda sebagai rujukan.", + "help.file.insert_step": "Menambah fail STEP, IGES, FreeCAD atau Rhino ke dalam reka bentuk sebagai jasad pepejal dalam komponen baharu; bahagian pemasangan menjadi komponen bersarang. Gunakan untuk membawa masuk model daripada pembekal atau program CAD lain.", + "help.file.save_mesh": "Menyimpan jasad sebagai mesh STL, 3MF atau OBJ, dengan kehalusan muka melengkung yang anda pilih. Gunakan untuk menyediakan bahagian bagi pencetakan 3D atau program yang membaca mesh.", + "help.file.send_to_slicer": "Menyimpan jasad yang dipilih sebagai fail 3MF dan membukanya dalam penghiris yang anda pilih. Gunakan untuk beralih daripada model kepada pencetakan dalam satu langkah.", "help.font_details": "Antara muka menggunakan fon terbina dalam egui (Ubuntu Light dan Hack, di bawah Lesen Fon Ubuntu dan lesen MIT/Bitstream Vera; Noto Emoji dan fon ikon emoji, di bawah OFL dan MIT). Teks lakaran menggunakan Liberation Sans (SIL OFL 1.1). Pek fon pilihan Crafting Apps, craft-fonts (BIZ UDPGothic, Shippori Mincho, BIZ UDMincho, Noto Sans CJK SC, Noto Sans Arabic; SIL OFL 1.1), tidak disertakan dalam binaan SolidCraft.", "help.fonts": "Fon", + "help.frame.member": "Meletakkan profil, seperti tiub atau aluminium tersemperit, sepanjang garis lakaran rangka, satu anggota bagi setiap garis. Gunakan untuk membina rangka, rak dan struktur kimpalan.", + "help.inspect.accessibility": "Mewarnakan muka yang dapat dicapai oleh alat dari satu arah, dan yang tidak dapat dicapai. Gunakan untuk menyemak bahagian bagi pemesinan atau pengacuanan dari sisi itu.", + "help.inspect.center_of_mass": "Memaparkan pusat jisim jasad, setiap satu secara berasingan dan semuanya bersama, pada ketumpatan seragam. Gunakan untuk menyemak keseimbangan dan tempat bahagian akan terbalik.", + "help.inspect.colored_components": "Memberi setiap komponen warna sementaranya sendiri, tanpa mengubah penampilan. Gunakan untuk membezakan bahagian pemasangan dengan sekilas pandang.", + "help.inspect.compare": "Membandingkan dua jasad dan menunjukkan di mana satu mempunyai bahan yang tiada pada yang lain, dengan isi padu dan sisihan. Gunakan untuk menyemak bahagian yang diubah atau diimport dengan yang asal.", + "help.inspect.continuity": "Menyemak kehalusan pertemuan muka di sepanjang tepinya: sela, patahan atau lonjakan kelengkungan, dan mewarnakan sambungannya. Gunakan untuk mencari tempat permukaan tidak bercantum dengan kemas.", + "help.inspect.curvature_comb": "Melukis sisir di sepanjang lengkung, tepi atau muka yang giginya menunjukkan sekuat mana ia melengkung. Gunakan untuk mengesan tempat rata, patahan dan kelengkungan yang tidak sekata.", + "help.inspect.curvature_map": "Mewarnakan muka mengikut sekuat mana ia melengkung, merah di tempat jejari kecil. Gunakan untuk mencari tempat yang sempit dan menilai kehalusan sesuatu permukaan.", + "help.inspect.draft": "Mewarnakan muka mengikut sudut draf terhadap arah tarikan, dan menandakan yang sudutnya terlalu kecil. Gunakan untuk menyemak bahagian sebelum pengacuanan atau penuangan.", + "help.inspect.environment_map": "Memaparkan model dengan kemasan memantul seperti cermin. Gunakan untuk menilai aliran permukaan melalui cara pantulan melintasinya.", + "help.inspect.interference": "Mencari jasad yang bertindih dan menunjukkan setiap pertindihan dengan isi padunya. Gunakan untuk menyemak pemasangan bagi bahagian yang berlanggar.", + "help.inspect.isocurve": "Melukis lengkung isoparametrik pada muka, iaitu grid tempat permukaan dibina. Gunakan untuk melihat aliran permukaan dan tempat ia terkumpul.", + "help.inspect.measure": "Mengukur panjang, jarak, sudut, luas dan jejari antara apa yang anda pilih, atau isi padu dan jisim jasad. Gunakan untuk menyemak saiz dan kelegaan.", + "help.inspect.minimum_radius": "Mencari jejari kelengkungan terkecil bagi lengkung, tepi atau muka, dan tempatnya. Gunakan untuk menyemak bahawa alat atau muncung dapat mengikuti bentuk itu.", + "help.inspect.section": "Membelah pandangan di sepanjang satah dan melorek muka yang dipotong, tanpa mengubah model. Gunakan untuk melihat ke dalam bahagian dan pemasangan.", + "help.inspect.zebra": "Memaparkan jalur hitam dan putih yang memantul pada muka model. Gunakan untuk menilai kehalusan: jalur yang putus atau patah di tepi menunjukkan peralihan yang kurang baik.", + "help.joint.as_built": "Menyambung dua bahagian di tempat ia berada, tanpa menggerakkan mana-mana satu, dan menetapkan cara ia masih boleh bergerak. Gunakan untuk bahagian yang sudah anda letakkan pada kedudukan akhirnya.", + "help.joint.cam": "Memaut sesondol yang berputar kepada pengikut, supaya pengikut menggelongsor atau berayun apabila garis luar sesondol melaluinya. Gunakan untuk sesondol, eksentrik dan pemacu injap.", + "help.joint.create": "Menyambung dua bahagian pada titik asal sendi yang anda pilih dan menggerakkan bahagian kedua ke tempatnya; jenis sendi menentukan cara ia masih boleh bergerak. Gunakan untuk membina pemasangan yang bahagiannya bergerak seperti mekanisme sebenar.", + "help.joint.drive": "Menetapkan sudut atau jarak sesebuah sendi dan menggerakkan bahagian bersamanya, dalam had sendi itu; sendi terpaut dan gelung tertutup turut mengikut. Gunakan untuk menyemak bahawa mekanisme bergerak seperti yang dimaksudkan.", + "help.joint.gear": "Memaut dua sendi berputar dengan nisbah gear, supaya menggerakkan satu akan memutar yang lain. Gunakan untuk gear taji dan gear dalaman, dengan nisbah daripada jejari pic atau bilangan gigi.", + "help.joint.limits": "Menetapkan sejauh mana sendi boleh berputar atau menggelongsor, sebagai minimum dan maksimum, atau mengosongkan had itu. Gunakan untuk memastikan engsel, gelangsar atau tuil kekal dalam julat pergerakan sebenarnya.", + "help.joint.motion_link": "Memaut dua sendi dengan nisbah dan ofset, supaya menggerakkan satu akan menggerakkan yang lain. Gunakan untuk hubungan tetap antara sendi yang tidak diliputi oleh Gear, Rak dan Pinan atau Skru.", + "help.joint.origin": "Meletakkan titik asal sendi bernama pada sesuatu bahagian: di pusat muka atau hujung bulat, tengah tepi, sudut atau satu titik. Gunakan untuk menyediakan tempat sendi melekat; titik asal yang dipilih pada muka mengikutinya apabila saiz bahagian berubah.", + "help.joint.rack_pinion": "Memaut pinan berputar kepada rak menggelongsor, supaya rak bergerak sepanjang bulatan pic yang digelek oleh pinan. Gunakan untuk pemacu rak, stereng dan pentas linear.", + "help.joint.reach": "Menggerakkan satu titik pada bahagian ke arah sasaran dengan mengubah sendi pada rantaiannya, dalam had sendi itu. Gunakan untuk melaraskan mekanisme mengikut kedudukan hujungnya, seperti hujung lengan robot.", + "help.joint.rigid_group": "Mengunci bahagian yang dipilih bersama seperti sedia ada, supaya ia bergerak sebagai satu. Gunakan untuk bahagian yang bersatu dan tiada gerakan antara satu sama lain.", + "help.joint.screw": "Memaut putaran dan gelongsoran melalui pic, supaya setiap pusingan menggerakkan jarak itu. Gunakan untuk skru pemacu, bolt dan gelendong berbebenang.", + "help.library.apply": "Menggunakan bahan atau penampilan daripada fail koleksi setempat pada jasad, muka atau komponen. Gunakan untuk menggunakan semula bahan yang anda simpan daripada reka bentuk lain.", + "help.library.export": "Menyimpan bahan dan penampilan reka bentuk ke fail koleksi. Gunakan untuk berkongsinya atau menggunakannya semula dalam reka bentuk lain.", + "help.library.inspect": "Menyemak fail koleksi dan menyenaraikan bahan dan penampilannya, tanpa mengubah reka bentuk. Gunakan untuk melihat kandungan koleksi sebelum menggunakannya.", "help.licences": "Lesen", "help.material.assign": "Menetapkan bahan jasad yang dipilih, seperti keluli, aluminium atau ABS. Gunakan supaya jisim dan berat sepadan dengan bahagian sebenar.", + "help.material.properties.validate": "Menyemak kad data bahan untuk nombor yang konsisten dan nilai yang hilang. Gunakan sebelum bergantung pada data itu untuk jisim atau kekakuan.", + "help.mesh.convert": "Membina semula jasad mesh, seperti STL, sebagai pepejal boleh sunting dengan muka rata dan silinder. Gunakan untuk mengubah suai bahagian mekanikal yang diimport seperti jasad yang dimodelkan.", "help.mit": "Lesen MIT", + "help.motion.study": "Menetapkan nilai sendi pada beberapa langkah sepanjang garis masa dan memainkannya semula sebagai animasi. Gunakan untuk menonton mekanisme bergerak melalui satu urutan dan menyemak kelegaan sepanjangnya.", + "help.motion.trace": "Menjejak laluan yang diikuti satu titik pada bahagian semasa kajian gerakan dimainkan atau sendi digerakkan, dan menyimpannya sebagai lengkung lakaran 3D apabila anda mengesahkan. Gunakan untuk melihat sejauh mana mekanisme mencapai, atau untuk membina berdasarkan laluannya.", "help.notice": "NOTIS", + "help.occurrence.ground": "Menetapkan bahagian pada tempatnya, atau membebaskannya semula, supaya sendi menggerakkan bahagian lain di sekelilingnya. Gunakan untuk rangka atau tapak yang menjadi asas seluruh pemasangan.", "help.occurrence.move": "Mengalihkan bahagian pemasangan hanya mengikut kebebasan yang dibenarkan oleh sendinya. Pilih muka bahagian, kemudian seret anak panah atau lengkok yang tersedia.", "help.parameters.change": "Membuka jadual nilai dan ungkapan bernama yang memacu dimensi reka bentuk. Gunakan untuk menukar saiz di satu tempat dan membiarkan setiap ciri yang menggunakannya mengikut.", + "help.parameters.export": "Menyimpan parameter reka bentuk sebagai CSV atau JSON. Gunakan untuk menyuntingnya dalam hamparan atau memindahkannya ke reka bentuk lain.", + "help.parameters.graph": "Menunjukkan parameter dan ciri mana yang menggunakan yang mana, dan sebarang gelung atau ralat di antaranya. Gunakan untuk melihat apa yang akan terjejas oleh perubahan pada sesuatu parameter.", + "help.parameters.import": "Membaca parameter daripada CSV atau JSON ke dalam reka bentuk, semuanya atau tiada satu pun. Gunakan untuk membawa masuk nilai daripada hamparan atau reka bentuk lain.", + "help.parameters.move": "Mengalihkan parameter pengguna sebelum atau selepas parameter lain dalam senarai. Gunakan untuk memastikan parameter yang berkaitan berada bersama.", + "help.parts.fastener": "Menambah skru, bolt, nat, sesendal, pin atau galas piawai dalam saiz yang anda pilih. Apabila diletakkan pada lubang, ia duduk dalam lubang itu dengan sendi, jadi anda tidak perlu memodelkan pengikat sendiri.", + "help.parts.insert": "Menyisip bahagian piawai dalam keluarga, saiz dan panjang yang anda pilih. Apabila diletakkan pada pinggir atau dinding lubang, ia duduk pada lubang itu dengan sendi tegar.", + "help.parts.library": "Membuka bahagian piawai: skru, bolt, nat, sesendal, skru penetap, pin dowel dan galas dalam saiz piawai. Gunakan untuk menambah bahagian piawai dan bukannya memodelkannya sendiri.", + "help.parts.resize": "Mengubah saiz atau panjang bahagian piawai yang disisipkan dan membinanya semula; sendinya mengekalkannya pada tempat. Gunakan apabila skru ternyata terlalu panjang atau lubang memerlukan saiz lain.", + "help.plastic.assign_rule": "Menetapkan peraturan plastik pada jasad, supaya ciri plastiknya menggunakan sudut draf dan kelegaan peraturan itu sebagai lalai. Gunakan untuk menyediakan bahagian teracu sekali sahaja bagi semua cirinya.", + "help.plastic.boss": "Menambah bonggol skru pada muka: tiang dengan lubang pilihan, sudut draf, jejari sudut pada akar dan rusuk penyokong. Gunakan untuk titik skru dalam perumah teracu atau bercetak.", + "help.plastic.lip": "Menambah bibir atau alur sepadan di sepanjang pinggir jasad yang diberi ronggak. Gunakan supaya dua separuh perumah duduk pada satu sama lain dan tertutup rapat.", + "help.plastic.manage_rules": "Mencipta dan menyunting peraturan plastik: bahan, ketebalan dinding, jejari, kelegaan dan sudut draf. Gunakan untuk memastikan ciri bahagian teracu konsisten.", + "help.plastic.rest": "Menambah penyokong pada muka: pad bulat atau segi empat tepat, pepejal atau berongga, dengan sudut draf. Gunakan sebagai sokongan atau tiang pengasing dalam perumah teracu.", + "help.plastic.snap_fit": "Menambah cangkuk sambungan sentap pada muka, dengan panjang, ketebalan dan penahannya. Gunakan untuk mengklip bahagian bersama tanpa skru.", "help.pointcloud.insert": "Sisipkan imbasan PLY, XYZ atau PTS sebagai awan titik di samping model. Gunakannya sebagai rujukan semasa membina model daripada data yang diukur.", + "help.sheet.convert": "Menukar plat atau jasad beronggak yang berketebalan sekata kepada jasad logam kepingan, dengan lenturan pada tepi yang anda pilih. Gunakan untuk menjadikan bahagian sedia ada boleh dibuka lipatan.", + "help.sheet.export_bend_table": "Menyimpan jadual lenturan jasad logam kepingan sebagai CSV atau JSON: sudut, jejari dan kedudukan lenturan dalam bentangan rata. Gunakan untuk brek tekan atau bengkel.", + "help.sheet.export_dxf": "Menyimpan bentangan rata sebagai DXF, dengan garis luar, garis lenturan dan nota lenturan pilihan. Gunakan untuk pemotongan laser, plasma atau jet air.", + "help.sheet.flange": "Menambah bebibir: plat tapak daripada lakaran, dinding di sepanjang kontur lakaran terbuka, atau dinding berlentur pada tepi kepingan. Gunakan untuk membina bahagian logam kepingan.", + "help.sheet.flat_pattern": "Mencipta bentangan rata jasad logam kepingan, dengan saiz, potongan dan garis lenturannya. Gunakan untuk menyemak kepingan mentah sebelum anda memotongnya.", + "help.sheet.fold": "Melipat kepingan di sepanjang garis lakaran mengikut sudut dan jejari. Gunakan untuk melipat kepingan rata selepas memotong ciri padanya.", + "help.sheet.hem": "Melipat tepi kepingan kembali ke atas dirinya. Gunakan untuk tepi yang selamat dan kukuh tanpa duri tajam.", + "help.sheet.manage_rules": "Mencipta dan menyunting peraturan logam kepingan: ketebalan, jejari lenturan, faktor K, pelega dan sela. Gunakan untuk menyesuaikan dengan bahan dan alat bengkel anda.", + "help.sheet.path_flange": "Menambah rantaian bebibir di sepanjang tepi kepingan, mengikut rantaian terbuka garis lakaran. Gunakan untuk profil seperti bentuk U atau Z dalam satu langkah.", + "help.sheet.perforate": "Menambah medan lubang di seluruh kepingan rata, dalam corak berselang-seli atau segi empat sama. Gunakan untuk lubang angin, gril dan panel yang lebih ringan.", + "help.sheet.refold": "Melipat semula jasad logam kepingan yang telah dibuka lipatan. Gunakan selepas menambah ciri pada kepingan rata.", + "help.sheet.unfold": "Membuka lipatan semua lenturan jasad logam kepingan. Gunakan untuk menambah lubang atau potongan yang perlu melintasi lenturan, kemudian lipat semula.", + "help.sketch.arc.center_point": "Melukis lengkok bulat di sekeliling titik pusat, daripada titik mula ke titik akhir. Gunakan apabila pusat lengkok diketahui, seperti jejari di sekeliling lubang atau pangsi.", + "help.sketch.arc.tangent": "Melukis lengkok yang bermula di hujung garis atau lengkok dan bersambung secara tangen dengannya. Gunakan untuk meneruskan rantaian dengan lenturan licin dan bukannya sudut.", + "help.sketch.arc.three_point": "Melukis lengkok daripada titik mula ke titik akhir melalui titik ketiga. Gunakan apabila lengkok perlu melalui titik yang diberi dan bukannya mengelilingi pusat yang diketahui.", + "help.sketch.arc3d": "Melukis lengkok bulat tepat dalam ruang melalui tiga titik, dengan Lakaran 3D aktif. Gunakan untuk lenturan pada laluan dan rel pemandu yang keluar daripada satah lakaran.", + "help.sketch.auto_constrain": "Menambah kekangan yang hampir dimiliki lakaran, seperti mendatar, selari atau bertindih tepat, kemudian mendimensikannya sehingga tertakrif sepenuhnya. Gunakan untuk mengunci lakaran yang dilukis secara kasar dalam satu langkah.", + "help.sketch.auto_constrain_from_datum": "Berfungsi seperti Kekangan Automatik, tetapi mengukur kedudukan daripada titik datum yang anda pilih dan bukannya titik asal. Gunakan apabila bahagian disusun daripada satu titik rujukan, seperti sudut atau lubang.", + "help.sketch.blend_curve": "Menyambung hujung dua lengkung dengan splin licin yang tangen kepada kedua-duanya, atau juga selanjar kelengkungan jika anda pilih. Gunakan untuk merapatkan jurang antara lengkung tanpa patahan.", + "help.sketch.break": "Memutuskan lengkung kepada beberapa keping di tempat lengkung lain melintasinya, atau pada titik yang anda letakkan padanya. Gunakan apabila sebahagian lengkung memerlukan kekangannya sendiri atau patut dipadam secara berasingan.", + "help.sketch.cancel": "Meninggalkan lakaran dan membuang semua yang diubah sejak ia dicipta atau dibuka untuk disunting. Buat Asal mengembalikan perubahan itu.", + "help.sketch.centerline": "Menukar garis antara garis biasa dan garis tengah, paksi yang diabaikan oleh profil. Gunakan garis tengah sebagai paksi untuk Putar, Cermin atau kekangan simetri.", + "help.sketch.chamfer.distance_angle": "Menyerongkan sudut antara dua garis, ditetapkan oleh jarak sepanjang garis pertama dan satu sudut. Gunakan apabila lukisan memberikan serong sebagai panjang dan sudut, seperti 2 mm × 30°.", + "help.sketch.chamfer.equal_distance": "Menyerongkan sudut antara dua garis dengan jarak yang sama ke belakang sepanjang kedua-duanya. Gunakan untuk serong 45° yang biasa pada garis luar.", + "help.sketch.chamfer.two_distance": "Menyerongkan sudut antara dua garis, ditetapkan oleh jarak berasingan sepanjang setiap garis. Gunakan untuk serong yang bukan 45°, diberikan sebagai dua panjang.", + "help.sketch.circle.center": "Melukis bulatan daripada pusat dan diameternya. Gunakan untuk lubang, bonggol dan ciri bulat lain yang pusatnya diketahui.", + "help.sketch.circle.three_point": "Melukis bulatan melalui tiga titik. Gunakan apabila bulatan perlu melalui titik yang diberi, contohnya pada geometri sedia ada.", + "help.sketch.circle.three_tangent": "Melukis bulatan yang menyentuh tiga garis, bulatan atau lengkok. Gunakan untuk memuatkan bulatan dalam ruang antara tiga lengkung, seperti bulatan terterap dalam segi tiga.", + "help.sketch.circle.two_point": "Melukis bulatan daripada dua hujung diameternya. Gunakan apabila titik tepi bulatan diketahui dan bukannya pusatnya.", + "help.sketch.circle.two_tangent": "Melukis bulatan bersaiz tertentu yang menyentuh dua garis, bulatan atau lengkok. Gunakan untuk pembulatan berjejari tetap antara dua lengkung.", + "help.sketch.conic": "Melukis lengkung kon antara dua titik hujung, dibentuk oleh titik puncak dan nilai rho. Gunakan untuk peralihan licin yang kepenuhannya hendak anda laraskan, daripada rata hingga hampir sudut tajam.", + "help.sketch.constrainer": "Menggunakan kekangan yang hampir dimiliki satu atau dua lengkung yang dipilih, seperti mendatar, selari, tangen atau sama. Gunakan untuk mengemaskan lakaran lukisan tangan satu hubungan demi satu hubungan.", + "help.sketch.constraint.coincident": "Menjadikan satu titik bertindih tepat dengan titik lain, lengkung atau paksi lakaran. Gunakan untuk menutup jurang dalam profil atau untuk meletakkan titik pada garis.", + "help.sketch.constraint.collinear": "Menjadikan dua garis terletak pada satu garis lurus. Gunakan untuk menjajarkan tepi yang terputus, seperti kedua-dua sisi takuk.", + "help.sketch.constraint.concentric": "Memberi dua bulatan atau lengkok pusat yang sama. Gunakan untuk gelang, lubang benam rata dan aci bertingkat.", + "help.sketch.constraint.curvature": "Menjadikan dua lengkung yang bertemu di titik hujung bersambung dengan kelengkungan yang sama (G2). Gunakan apabila sambungan tangen tidak cukup licin, contohnya pada permukaan yang kelihatan.", + "help.sketch.constraint.equal": "Menjadikan garis sama panjang, atau bulatan dan lengkok sama jejari. Gunakan supaya satu dimensi memacu beberapa ciri yang sepadan.", + "help.sketch.constraint.fix": "Menetapkan titik atau lengkung di tempatnya, atau melepaskannya semula. Gunakan untuk geometri yang tidak boleh bergerak apabila anda menyeret atau menyelesaikan lakaran.", + "help.sketch.constraint.horizontal_vertical": "Menjadikan garis, atau garis antara dua titik, mendatar atau menegak, mana-mana yang lebih hampir. Gunakan untuk meluruskan garis luar mengikut paksi lakaran.", + "help.sketch.constraint.midpoint": "Meletakkan titik di tengah garis, atau menjadikan titik tengah dua garis bertemu. Gunakan untuk memusatkan geometri pada tepi.", + "help.sketch.constraint.parallel": "Menjadikan dua garis selari. Gunakan untuk sisi yang mesti kekal selari apabila dimensi lain berubah.", + "help.sketch.constraint.perpendicular": "Menjadikan dua garis bertemu pada sudut tegak. Gunakan untuk sudut segi empat antara garis yang tidak berada pada paksi lakaran.", + "help.sketch.constraint.polygon": "Menjadikan rantaian garis tertutup sebagai poligon sekata: sisi sama panjang, dengan setiap sudut pada satu bulatan. Gunakan untuk menjadikan poligon lukisan tangan sekata dan mengekalkannya begitu.", + "help.sketch.constraint.symmetry": "Menjadikan dua titik atau lengkung saling bercermin merentasi garis atau mengelilingi titik. Gunakan untuk memastikan profil kekal simetri apabila anda mengubah satu sisi.", + "help.sketch.constraint.tangent": "Menjadikan dua lengkung bersentuhan secara tangen, seperti garis dan lengkok. Gunakan untuk sambungan licin tanpa patahan.", + "help.sketch.construction": "Menukar lengkung antara geometri biasa dan geometri binaan, yang diabaikan oleh profil. Gunakan lengkung binaan sebagai pembantu untuk susun atur, rujukan dan dimensi.", "help.sketch.construction_line": "Cipta garis binaan tak terhingga melalui dua titik. Gunakannya untuk menyusun lakaran tanpa memisahkan profil.", "help.sketch.construction_line.angle": "Cipta garis binaan tak terhingga pada sudut daripada paksi X lakaran. Pilih titik dan taip sudutnya, atau pilih arahnya.", "help.sketch.construction_line.bisector": "Pilih dua garis lakaran lurus yang bersilang, kemudian pilih salah satu pembahagi dua sudut. Hasilnya mengikut garis sumbernya.", @@ -1412,11 +1564,71 @@ "help.sketch.construction_line.tangent_external": "Pilih bulatan atau lengkok bulat, kemudian satu titik luar dan satu penyelesaian tangen. Pratonton hanya menunjukkan titik sentuh pada lengkok yang dilukis.", "help.sketch.construction_line.tangent_two": "Pilih dua bulatan atau lengkok bulat, kemudian pilih tangen sepunya dalam pratonton. Titik sentuh mesti terletak pada lengkung yang dilukis.", "help.sketch.construction_line.vertical": "Cipta garis binaan menegak tak terhingga melalui satu titik. Kekangan menegaknya kekal apabila disunting kemudian.", + "help.sketch.copy": "Menyalin lengkung dan titik lakaran yang dipilih, bersama kekangan dan dimensi di antaranya. Tampalkannya ke dalam lakaran ini atau lakaran lain.", + "help.sketch.create": "Memulakan lakaran 2D baharu pada satah atau muka rata model. Setiap Semperit, Putar dan Sapu bermula dengan lakaran.", + "help.sketch.cut": "Menyalin geometri lakaran yang dipilih dan memadamnya daripada lakaran, dalam satu langkah Buat Asal. Tampalkannya di tempat lain dalam lakaran ini atau ke dalam lakaran lain.", + "help.sketch.dimension": "Menambah dimensi pemacu: panjang, jarak, sudut, jejari atau diameter, bergantung pada apa yang anda pilih. Taip nilai atau nama parameter, dan lakaran mengikutinya.", + "help.sketch.edit": "Membuka lakaran sedia ada untuk disunting. Ciri yang dibina padanya dikemas kini apabila anda menamatkan lakaran.", + "help.sketch.ellipse": "Melukis elips daripada pusatnya, hujung paksi utama dan satu titik pada sisi minornya. Gunakan untuk bukaan bujur, sesondol dan bentuk bulat memanjang yang lain.", "help.sketch.equation_curve": "Melukis lengkung daripada formula: x(t) dan y(t), atau y = f(x), dalam satu julat. Formula boleh menggunakan parameter dan lengkung akan mengikutinya: gunakannya untuk gelombang sinus, lingkaran atau involut gigi gear.", + "help.sketch.extend": "Memanjangkan garis atau lengkok sehingga lengkung terdekat yang menghalang, atau memanjangkan dua lengkung sehingga bertemu. Gunakan untuk menutup jurang dalam profil selepas memangkas atau menyunting.", + "help.sketch.fillet": "Membulatkan sudut tempat dua garis atau lengkok bertemu dengan lengkok berjejari tertentu. Beberapa sudut yang dibulatkan bersama berkongsi satu jejari.", + "help.sketch.finish": "Meninggalkan lakaran dan kembali ke model. Profilnya yang tertutup kemudian sedia untuk Semperit, Putar dan ciri lain.", + "help.sketch.finish_auto_constrain": "Menjalankan Kekangan Automatik pada lakaran, kemudian menamatkannya. Gunakan untuk meninggalkan lakaran yang tertakrif sepenuhnya dalam satu langkah.", + "help.sketch.fit_curves_to_section": "Memotong jasad mesh atau pepejal dengan satah lakaran dan memadankan garis dan lengkok pada keratan itu. Gunakan untuk membina semula bahagian yang diimbas atau diimport sebagai lakaran boleh sunting.", + "help.sketch.include_3d": "Membawa tepi model, bucu dan lengkung lakaran lain ke dalam lakaran ini sebagai rujukan terpaut, dan meratakan yang tidak berada pada satahnya ke atasnya. Gunakan untuk membina berdasarkan geometri yang tidak terletak pada satah lakaran.", + "help.sketch.infinite": "Menukar garis antara garis biasa dan garis binaan tak terhingga yang melintasi seluruh pandangan. Gunakan sebagai rujukan susun atur yang tidak pernah terlalu pendek.", + "help.sketch.insert_dxf": "Membaca lengkung lukisan DXF ke dalam lakaran, diskalakan, diputar dan diletakkan mengikut tetapan anda. Gunakan untuk membina berdasarkan lukisan 2D daripada program lain, seperti garis luar potongan laser.", + "help.sketch.insert_svg": "Membaca laluan lukisan SVG ke dalam lakaran, diskalakan, diputar dan diletakkan mengikut tetapan anda. Gunakan untuk menukar logo atau garis luar vektor kepada lengkung yang boleh disemperit.", + "help.sketch.inspect": "Membuka laporan tentang lakaran yang terbuka: berapa banyak darjah kebebasan yang tinggal dan sama ada ia terkekang sepenuhnya. Gunakan untuk mengetahui apa yang masih bergerak sebelum anda menamatkan lakaran.", + "help.sketch.intersect": "Menambah lengkung atau titik tempat jasad, muka, tepi atau lakaran lain melintasi satah lakaran. Ia kekal terpaut dan mengikut model apabila model berubah.", + "help.sketch.intersection_curve": "Mencipta lengkung 3D tempat dua jasad atau muka bertemu, terpaut kepadanya. Gunakan sebagai laluan atau rel pemandu yang mengikut model.", + "help.sketch.isoparametric_curve": "Mencipta lengkung 3D di sepanjang arah U atau V muka melalui titik yang anda pilih, terpaut kepada muka itu. Gunakan sebagai laluan atau garis belahan yang mengikut permukaan.", + "help.sketch.line": "Melukis garis bersambung titik demi titik, sebagai rantaian terbuka atau garis luar tertutup. Ia ialah alat asas bagi kebanyakan profil.", + "help.sketch.line.midpoint": "Melukis garis daripada titik tengahnya ke satu hujung, dengan hujung satu lagi dicerminkan. Gunakan untuk garis yang berpusat pada satu titik, seperti tepi simetri.", + "help.sketch.line3d": "Melukis garis lurus bersambung antara titik dalam ruang, dengan Lakaran 3D aktif. Gunakan untuk laluan dan rangka yang keluar daripada satah lakaran.", + "help.sketch.mirror": "Mencerminkan lengkung dan titik lakaran merentasi garis atau paksi dan menambah kekangan simetri. Gunakan untuk melukis separuh profil simetri dan melengkapkannya.", + "help.sketch.move": "Mengalihkan atau memutar lengkung dan titik lakaran, atau salinannya. Gunakan untuk meletakkan semula geometri sambil mengekalkan bentuk dan kekangannya.", + "help.sketch.offset": "Mencipta salinan rantaian lengkung pada jarak tertentu, di satu sisi atau kedua-duanya. Gunakan untuk dinding, garis luar dan kelegaan; jarak kekal sebagai dimensi yang boleh disunting.", + "help.sketch.paste": "Menampal geometri lakaran yang anda salin atau potong ke dalam lakaran aktif. Letakkan pada satu titik atau anjakkan dengan ofset.", + "help.sketch.pattern.circular": "Menyalin geometri lakaran mengelilingi titik pusat, sepanjang bulatan penuh atau sudut tertentu. Gunakan untuk bulatan bolt, ruji dan gigi.", + "help.sketch.pattern.rectangular": "Menyalin geometri lakaran dalam baris dan lajur pada jarak tertentu. Gunakan untuk grid lubang, slot atau lubang angin.", + "help.sketch.point": "Meletakkan titik lakaran. Gunakan sebagai rujukan untuk dimensi, kekangan, pusat lubang atau ciri kemudian.", + "help.sketch.point3d": "Meletakkan titik dalam ruang, dengan Lakaran 3D aktif. Gunakan sebagai rujukan untuk lengkung dan laluan 3D.", + "help.sketch.polygon.circumscribed": "Melukis poligon sekata di sekeliling bulatan, bersaiz daripada pusatnya ke tengah satu sisi. Gunakan apabila lebar merentasi sisi rata diketahui, seperti pada nat heks.", + "help.sketch.polygon.edge": "Melukis poligon sekata daripada dua hujung satu sisi. Gunakan apabila panjang dan kedudukan satu sisi diketahui.", + "help.sketch.polygon.inscribed": "Melukis poligon sekata di dalam bulatan, bersaiz daripada pusatnya ke satu sudut. Gunakan apabila saiz merentasi sudut diketahui.", + "help.sketch.project": "Membawa tepi model, muka, bucu dan lengkung lakaran lain ke dalam lakaran ini, diunjurkan ke satahnya. Ia kekal terpaut dan mengikut model apabila model berubah.", + "help.sketch.project_to_surface": "Mengunjurkan lengkung lakaran lain di sepanjang normal lakaran ini ke atas muka, sebagai lengkung 3D terpaut. Gunakan untuk meletakkan logo, garis belahan atau laluan pada permukaan melengkung.", + "help.sketch.rectangle.center": "Melukis segi empat tepat daripada pusatnya dan satu sudut. Gunakan untuk garis luar yang berpusat pada satu titik, seperti titik asal.", + "help.sketch.rectangle.rounded": "Melukis segi empat tepat dengan keempat-empat sudut dibulatkan pada satu jejari. Gunakan untuk plat, penutup dan bukaan bersudut bulat.", + "help.sketch.rectangle.three_point": "Melukis segi empat tepat daripada satu tepi dan satu titik pada sisi bertentangan. Gunakan untuk segi empat tepat yang condong terhadap paksi lakaran.", + "help.sketch.rectangle.two_point": "Melukis segi empat tepat daripada dua sudut bertentangan. Gunakan untuk garis luar plat, blok dan poket.", + "help.sketch.scale": "Menskalakan lengkung dan titik lakaran dengan satu faktor mengikut titik asas. Gunakan untuk mengubah saiz sebahagian lakaran tanpa melukisnya semula.", + "help.sketch.slot.arc_center": "Melukis slot melengkung dengan lebar tertentu di sepanjang lengkok yang ditetapkan oleh pusat, permulaan dan penghujungnya. Gunakan untuk slot pelarasan di sekeliling pangsi.", + "help.sketch.slot.arc_three_point": "Melukis slot melengkung dengan lebar tertentu di sepanjang lengkok melalui tiga titik. Gunakan apabila laluan slot perlu melalui titik yang diberi.", + "help.sketch.slot.center_point": "Melukis slot lurus dengan lebar tertentu daripada pusatnya ke pusat satu lengkok hujung. Gunakan untuk slot yang berpusat pada satu titik.", + "help.sketch.slot.center_to_center": "Melukis slot lurus dengan lebar tertentu antara pusat kedua-dua lengkok hujungnya. Gunakan untuk slot yang didimensikan dari pusat ke pusat, seperti slot pelarasan untuk skru.", + "help.sketch.slot.overall": "Melukis slot lurus dengan lebar tertentu dari satu hujung luar ke hujung yang lain. Gunakan apabila panjang keseluruhan slot yang penting.", + "help.sketch.spline.control_point": "Melukis splin kubik yang dibentuk oleh titik kawalan yang tidak dilaluinya. Gunakan untuk lengkung licin dan mengalir yang anda bentuk dengan menggerakkan titik.", + "help.sketch.spline.control_point_5": "Melukis splin darjah 5 yang dibentuk oleh titik kawalan. Gunakan apabila lengkung perlu bersambung dengan kelengkungan yang lebih licin, contohnya pada permukaan bergaya yang kelihatan.", + "help.sketch.spline.fit_point": "Melukis splin licin yang melalui setiap titik yang anda letakkan, terbuka atau tertutup. Gunakan untuk menyurih bentuk melalui titik yang diketahui.", + "help.sketch.spline3d": "Melukis lengkung licin dalam ruang melalui titik, dengan Lakaran 3D aktif. Gunakan untuk laluan sapuan dan laluan kabel yang keluar daripada satah lakaran.", + "help.sketch.spun_profile": "Mencipta dalam satah lakaran garis luar yang dilalui oleh jasad apabila ia berputar pada paksi. Gunakan untuk mendapatkan siluet bahagian bubutan bagi Putar atau lukisan.", + "help.sketch.text": "Menulis teks dalam lakaran dengan fon, ketinggian dan sudut yang dipilih; perkataannya boleh mengikut parameter. Semperit atau timbulkan untuk label dan tulisan.", + "help.sketch.transform": "Menskalakan, memutar dan mengalihkan geometri lakaran mengelilingi titik pusat dalam satu langkah. Gunakan untuk meletakkan semula dan mengubah saiz pilihan sekali gus.", + "help.sketch.trim": "Membuang bahagian lengkung antara persilangan terdekat di sekeliling tempat anda mengklik. Gunakan untuk mengemaskan garis bertindih menjadi profil tertutup.", "help.solid.align": "Mengalihkan jasad yang dipilih supaya satu titik, muka atau satah padanya bertemu satu titik, muka atau satah di tempat lain. Gunakan untuk meletakkan satu bahagian pada bahagian lain tanpa mengukur ofset.", + "help.solid.boundary_fill": "Membuat pepejal daripada kawasan yang dikelilingi jasad alat yang bertindih, mengisi hanya kawasan yang anda pilih. Gunakan untuk membina bentuk daripada ruang yang disempadani bersama oleh beberapa jasad.", + "help.solid.bounding_solid": "Mencipta kotak yang mengurung jasad yang dipilih, dengan margin pilihan. Gunakan untuk melihat saiz keseluruhan bahagian, atau sebagai stok bahan yang daripadanya ia dimesin.", + "help.solid.box": "Mencipta kotak daripada panjang, lebar dan tinggi, daripada titik sudut atau berpusat pada satu titik. Gunakan untuk blok pantas, pemegang tempat atau jasad untuk dipotong ciri padanya.", "help.solid.chamfer": "Memotong tepi yang dipilih dengan potongan serong rata sejauh jarak yang anda tetapkan. Gunakan untuk menumpulkan tepi tajam atau memudahkan kemasukan pin atau skru.", + "help.solid.coil": "Mencipta jasad heliks, seperti pegas, daripada diameter, bilangan lilitan, ketinggian atau pic dan keratan bulat atau segi empat sama. Gunakan untuk pegas, gegelung dan rabung heliks.", "help.solid.combine": "Mencantum jasad, memotongnya daripada jasad sasaran, atau hanya mengekalkan isi padu yang dikongsi, dan boleh mengekalkan jasad alat. Gunakan untuk menggabungkan bahagian, memotong satu bentuk dengan bentuk lain atau mencari tempat dua jasad bertindih.", + "help.solid.cone": "Mencipta kon daripada bulatan tapak dan ketinggian, atau kon terpenggal apabila bahagian atas juga berjejari. Gunakan untuk tirus, corong, lubang benam tirus dan hujung tajam.", + "help.solid.cylinder": "Mencipta silinder daripada jejari atau diameter dan ketinggian, berdiri pada satu titik di sepanjang paksi. Gunakan untuk aci, pin, bonggol dan bakal bulat.", "help.solid.draft": "Mencondongkan muka yang dipilih mengikut sudut yang anda tetapkan, diukur dari satah neutral. Gunakan pada bahagian yang diacu atau dituang supaya boleh dikeluarkan dari acuan.", + "help.solid.emboss": "Menimbulkan profil lakaran, seperti teks atau logo, keluar daripada muka, atau membenamkannya ke dalamnya. Gunakan untuk tulisan, label dan tanda pada muka rata atau bulat.", "help.solid.extrude": "Menyemperit profil lakaran menjadi 3D sepanjang jarak yang ditetapkan, menambah atau membuang bahan. Gunakan untuk menukar garis bentuk rata menjadi blok, bonggol atau poket.", "help.solid.fillet": "Membulatkan tepi yang dipilih dengan peralihan bulat licin berjejari yang anda tetapkan. Gunakan untuk melembutkan tepi tajam demi kekuatan, keselesaan atau rupa.", "help.solid.hole": "Menggerudi lubang bulat berdiameter yang anda tetapkan pada titik terpilih pada muka. Gunakan untuk ruang skru, pin dan bukaan bulat ringkas yang lain.", @@ -1425,20 +1637,60 @@ "help.solid.move": "Mengalih atau memutar jasad yang dipilih, atau salinannya, mengikut jarak dan sudut yang ditetapkan. Gunakan untuk menempatkan semula jasad atau membuat satu lagi di tempat lain.", "help.solid.offset_face": "Menolak muka yang dipilih ke dalam atau ke luar mengikut jarak yang ditetapkan, dan muka di sekelilingnya turut mengikut. Gunakan untuk menebalkan dinding, mendalamkan poket atau meninggikan bonggol tanpa menyunting lakaran.", "help.solid.pattern.circular": "Mengulang objek yang dipilih mengelilingi paksi, mengikut bilangan dan jumlah sudut yang ditetapkan. Gunakan untuk bulatan bolt, jejari roda, sirip dan gigi.", + "help.solid.pattern.path": "Menyalin ciri atau jasad di sepanjang laluan lakaran, mengikut bilangan dan jarak. Gunakan untuk lubang di sepanjang tepi melengkung atau bahagian di sepanjang rel.", "help.solid.pattern.rectangular": "Mengulang objek yang dipilih dalam satu atau dua arah mengikut bilangan dan selang yang ditetapkan. Gunakan untuk baris dan grid lubang, rusuk, pin atau kaki.", + "help.solid.pipe": "Mencipta tiub bulat di sepanjang laluan lengkung lakaran, pepejal atau berongga dengan ketebalan dinding. Gunakan untuk tiub, wayar, kabel dan rel.", "help.solid.press_pull": "Menambah atau membuang bahan semasa anda menyeret muka, atau membulatkan tepi semasa anda menyeretnya. Gunakan untuk perubahan pantas terus pada model.", "help.solid.remove": "Mengeluarkan jasad yang dipilih daripada reka bentuk mulai titik ini dalam garis masa; langkah terdahulu masih mengekalkannya. Gunakan untuk menggugurkan jasad bantu, seperti alat pemotong yang tidak diperlukan lagi.", "help.solid.replace_face": "Mengalihkan muka rata yang dipilih ke satah atau ke muka selari, dan jasad membesar atau mengecil untuk bertemu dengannya. Gunakan untuk menyamakan aras muka dengan bahagian lain atau satah binaan.", "help.solid.revolve": "Memutar profil lakaran mengelilingi paksi untuk menghasilkan pepejal bulat. Gunakan untuk aci, tombol, mangkuk dan mana-mana bahagian yang bulat di sekeliling garis tengah.", + "help.solid.rib": "Menambah dinding nipis di sepanjang lengkung lakaran terbuka, diisi sehingga muka seterusnya pada jasad. Gunakan untuk mengukuhkan dinding dan sudut, contohnya pada bahagian teracu atau bercetak.", "help.solid.scale": "Membesarkan atau mengecilkan jasad yang dipilih mengikut faktor, sama ke semua arah atau satu bagi setiap paksi. Gunakan untuk mengubah saiz bentuk yang diimport atau untuk mengambil kira pengecutan.", "help.solid.shell": "Mengosongkan bahagian dalam pepejal, meninggalkan dinding setebal yang ditetapkan, dan boleh membuka muka yang anda pilih. Gunakan untuk kotak, penutup dan bahagian berdinding nipis yang lain.", + "help.solid.sphere": "Mencipta sfera daripada jejari atau diameter mengelilingi titik pusat. Gunakan untuk bebola, tombol dan hujung bulat.", "help.solid.split_body": "Memotong jasad kepada beberapa jasad berasingan menggunakan satah, muka jasad lain atau garis lakaran. Gunakan untuk menghasilkan bahagian yang dicetak, dimesin atau diwarnakan secara berasingan.", "help.solid.split_face": "Membahagikan muka yang dipilih di sepanjang satah, muka jasad lain atau lengkung lakaran, tanpa memotong jasad. Gunakan untuk memberikan sebahagian muka sudut draf, warna atau ofset tersendiri.", "help.solid.sweep": "Menyeret profil lakaran di sepanjang laluan untuk menghasilkan pepejal. Gunakan untuk paip, rel, pemegang dan mana-mana bahagian berkeratan malar yang mengikut lengkung.", + "help.solid.thread": "Menambah bebenang metrik ISO pada muka silinder, sama ada sebagai bebenang kosmetik atau dipotong ke dalam jasad. Gunakan untuk bolt, lubang berbebenang dan bahagian skru bercetak.", + "help.solid.torus": "Mencipta gelang daripada jejari utama mengelilingi paksinya dan jejari minor untuk keratan bulatnya. Gunakan untuk gelang-O, pemegang dan manik bulat.", + "help.solid.web": "Menambah dinding nipis di sepanjang setiap lengkung lakaran terbuka, masing-masing diisi sehingga muka seterusnya pada jasad. Gunakan untuk grid dinding penyokong di dalam perumah dan dulang.", + "help.surface.blend": "Mencipta permukaan yang menyambung tepi dua jasad, tangen atau selanjar kelengkungan kepada kedua-duanya. Gunakan untuk menyambung dua permukaan dengan licin merentasi jurang.", + "help.surface.boundary": "Mencipta permukaan yang mengisi gelung dua hingga empat lengkung lakaran, atau rangkaian lengkung. Gunakan untuk panel bentuk bebas yang ditakrifkan oleh tepinya.", + "help.surface.extend": "Memanjangkan permukaan melepasi tepinya, mengikut jarak atau sehingga satah atau muka. Gunakan supaya permukaan bertindih sebelum anda memangkas atau mencantumnya.", + "help.surface.extrude": "Menolak lengkung atau profil lakaran sepanjang satu jarak menjadi permukaan, dinding sahaja, tanpa penutup. Gunakan untuk dinding nipis dan untuk alat pemotong atau pembelah.", + "help.surface.fair": "Melicinkan bahagian dalam muka bentuk bebas sementara tepinya kekal. Gunakan untuk meratakan bonjolan dan pantulan beralun pada permukaan.", + "help.surface.loft": "Mencipta permukaan yang melalui dua atau lebih keratan mengikut urutan, tanpa penutup. Gunakan untuk cangkerang dan peralihan antara bentuk yang berbeza.", + "help.surface.match": "Membina semula tepi permukaan supaya bertemu dengan tepi jasad lain, dengan tangen atau kelengkungan yang sepadan. Gunakan untuk menutup sambungan dengan licin antara dua permukaan.", + "help.surface.offset": "Mencipta salinan muka pada satu jarak di sepanjang normalnya, sebagai jasad permukaan baharu. Gunakan untuk kulit, kelegaan atau sisi lain sesuatu dinding.", + "help.surface.patch": "Mengisi kawasan lakaran atau gelung tertutup tepi dengan muka, rata atau melengkung licin. Gunakan untuk menutup lubang pada jasad permukaan.", + "help.surface.reverse_normal": "Membalikkan muka jasad permukaan, supaya bahagian luarnya menghadap arah lain. Gunakan sebelum menebalkan atau mencantum apabila muka menghala ke dalam.", + "help.surface.revolve": "Memutar lengkung atau profil lakaran pada paksi menjadi permukaan, tanpa penutup. Gunakan untuk cangkerang bulat seperti teduhan lampu, mangkuk dan muncung.", + "help.surface.ruled": "Mencipta jalur garis lurus keluar daripada rantaian tepi, sepanjang normal muka, tangen atau pada satu sudut. Gunakan untuk dinding draf, kawasan lari-keluar dan permukaan perpisahan.", + "help.surface.stitch": "Mencantum jasad permukaan bersama di sepanjang tepi yang dikongsi, menjadi pepejal apabila ia tertutup. Gunakan untuk menjadikan sekumpulan permukaan satu jasad.", + "help.surface.sweep": "Menggerakkan lengkung atau profil lakaran di sepanjang laluan menjadi permukaan, tanpa penutup. Gunakan untuk alur air, perapi dan bentuk nipis lain yang mengikut laluan.", + "help.surface.thicken": "Memberi ketebalan kepada jasad permukaan, menjadikannya pepejal. Gunakan untuk membuat bahagian yang boleh dicetak atau dimesin daripada permukaan.", + "help.surface.trim": "Memotong permukaan dengan satah, permukaan lain atau lengkung lakaran, dan mengekalkan sisi yang anda pilih. Gunakan untuk memotong permukaan mengikut bentuk sebelum dicantum.", + "help.surface.unstitch": "Melerai muka jasad menjadi jasad permukaan tersendiri; pepejal terlerai menjadi permukaan. Gunakan untuk mengolah semula muka tunggal pada jasad.", + "help.surface.untrim": "Membuang lubang daripada muka, atau memulihkan sempadan luarnya kepada had semula jadi permukaan. Gunakan untuk mendapatkan semula permukaan penuh selepas dipangkas.", "help.third_party": "Lesen pihak ketiga", "help.timeline.compute_all": "Membina semula setiap ciri dalam garis masa dari awal. Gunakan untuk menyegarkan model dan melihat ciri yang gagal.", "help.timeline.delete": "Memadam ciri yang dipilih daripada garis masa; memadam lakaran turut memadam ciri yang dibina daripadanya. Gunakan untuk membuang langkah yang tidak lagi diperlukan.", + "help.timeline.dependents": "Menyenaraikan ciri yang akan dipadam bersama sesuatu ciri, dan yang akan gagal tanpanya. Gunakan sebelum memadam atau mengubah ciri.", "help.timeline.play": "Main semula ciri yang tinggal satu demi satu, dengan jeda setengah saat selepas setiap pembinaan semula. Jalankan perintah ini sekali lagi atau tekan Escape untuk berhenti; Pergi ke penghujung melompat serta-merta.", + "help.view.apply": "Memutar pandangan ke arah piawai seperti hadapan, atas atau iso, atau ke pandangan bernama yang disimpan. Gunakan untuk melihat model dari sudut tetap.", + "help.view.delete": "Memadam pandangan bernama yang disimpan. Model tidak berubah.", + "help.view.fit": "Menzum dan menyorot supaya seluruh model, atau pilihan, memenuhi pandangan. Gunakan apabila model telah hilang daripada pandangan.", + "help.view.grid": "Memaparkan atau menyembunyikan grid dalam tetingkap pandangan. Gunakan sebagai panduan saiz dan orientasi semasa memodelkan.", + "help.view.ground_shadow": "Memaparkan atau menyembunyikan bayang model pada lantai. Gunakan untuk menilai ketinggian dan kedudukan dengan lebih mudah.", + "help.view.home": "Memutar pandangan kembali ke arah utama dan memuatkan model. Gunakan untuk kembali ke pandangan yang diketahui dengan cepat.", + "help.view.list": "Menyenaraikan pandangan bernama yang disimpan dalam reka bentuk. Gunakan untuk mencari pandangan yang anda simpan sebelum ini.", + "help.view.navigate": "Memutar, menyorot atau menzum pandangan mengikut langkah, seperti suku pusingan ke kiri. Gunakan untuk menggerakkan kamera dengan tepat dan bukannya menyeret.", + "help.view.navigation_bar": "Memaparkan atau menyembunyikan bar navigasi di bahagian bawah tetingkap pandangan. Sembunyikan untuk ruang lebih luas, paparkan untuk mencapai alat pandangan dengan tetikus.", + "help.view.rename": "Memberi nama baharu kepada pandangan bernama yang disimpan. Pandangan itu sendiri kekal sama.", + "help.view.save": "Menyimpan kedudukan kamera semasa sebagai pandangan bernama dalam reka bentuk. Gunakan untuk kembali ke sudut yang sama kemudian, contohnya untuk tangkapan skrin.", + "help.view.spin": "Mengorbit pandangan mengelilingi model secara berterusan sehingga klik atau kekunci seterusnya. Gunakan untuk mempersembahkan model dari semua sisi.", + "help.view.tool": "Mengunci alat kamera, orbit, sorot atau zum, supaya menyeret dengan butang kiri tetikus menggerakkan pandangan sehingga anda mematikannya. Gunakan pada pad jejak atau tanpa butang tengah tetikus.", + "help.view.visual_style": "Menukar cara model dilukis: berlorek dengan atau tanpa tepi, rangka dawai, atau dengan tepi tersembunyi. Gunakan rangka dawai untuk melihat ke dalam, berlorek untuk rupa yang bersih.", "history.sketch_under_constrained": "Kurang terkekang ({count} darjah kebebasan)", "history.sketch_under_constrained_one": "Kurang terkekang ({count} darjah kebebasan)", "hole_presets.confirm_delete": "Sahkan pemadaman", @@ -1615,6 +1867,10 @@ "material.selection.mixed": "Bahan berbeza", "measure.at": "Pada", "measure.item": "Item {number}", + "measure.quick_copied": "Ukuran disalin", + "measure.quick_copy": "Klik untuk menyalin ukuran", + "measure.quick_edges": "{count} tepi", + "measure.quick_faces": "{count} muka", "messages.ago_h": "{n} j lalu", "messages.ago_min": "{n} min lalu", "messages.ago_s": "{n} s lalu", @@ -1767,6 +2023,11 @@ "not a move": "bukan gerakan", "not a number": "bukan nombor", "open a sketch first": "buka lakaran dahulu", + "parameters.apply_live": "Gunakan setiap perubahan apabila anda meninggalkan medannya", + "parameters.apply_live_tip": "Mati: perubahan menunggu sehingga Gunakan atau OK, kemudian reka bentuk dibina semula sekali untuk semuanya. Batal atau Escape membuangnya.", + "parameters.held": "{count} perubahan menunggu Gunakan", + "parameters.held_hint": "Sunting ungkapan dan unit. Perubahan menunggu Gunakan atau OK.", + "parameters.held_one": "1 perubahan menunggu Gunakan", "parts.automatic_name": "automatik", "parts.dimension.across_flats": "Merentasi rata", "parts.dimension.bore": "Diameter lubang", @@ -1789,6 +2050,8 @@ "parts.family.set_screw": "Skru penetap soket heks, hujung rata", "parts.family.socket_head_cap_screw": "Skru kepala soket", "parts.family.washer": "Sesendal biasa", + "parts.holes": "Lubang (pilihan)", + "parts.placement_hint": "pilih satu atau lebih lubang untuk meletakkan bahagian pada setiap satu, atau tekan OK untuk meletakkannya di titik asal", "pattern.alignment_2": "Penjajaran 2", "pattern.alignment_hint": "Bilangan termasuk asal. Tengah seimbangkan salinan; Balik pilih sisi lebih untuk bilangan genap.", "pattern.direction_1": "Arah 1", @@ -1955,6 +2218,8 @@ "prefs.origin_plane_names": "Nama satah asal", "prefs.origin_plane_names_note": "Nama yang dipaparkan dalam pelayar, dialog dan senarai. {top} ialah XY, {front} ialah XZ dan {right} ialah YZ, dengan Z ke atas. Arahan, skrip dan reka bentuk yang disimpan kekal menggunakan XY, XZ dan YZ.", "prefs.overall_speed": "Kelajuan keseluruhan", + "prefs.parameter_list_rows": "Baris senarai parameter", + "prefs.parameter_list_rows_tip": "Anak panah ke bawah dalam medan nilai kosong menyenaraikan parameter, kegemaran dahulu, sehingga bilangan ini. Bilangan yang lebih kecil memastikan senarai ringan dalam reka bentuk besar.", "prefs.persistent_ribbon_pressed": "Serlahkan arahan reben yang aktif", "prefs.persistent_ribbon_pressed_help": "Serlahkan arahan reben selagi kesannya dihidupkan. Matikan untuk mengekalkan rupa neutral dan bantuan arahan yang terdahulu.", "prefs.perspective_fov": "Medan pandangan", @@ -1984,6 +2249,8 @@ "prefs.quality_fastest": "terpantas", "prefs.quality_slowest": "terperlahan", "prefs.quality_smoother": "lebih licin", + "prefs.quick_measure": "Ukuran pantas di sudut pandangan", + "prefs.quick_measure_note": "Saiz pilihan di sudut kanan bawah pandangan: panjang tepi, atau Ø dan R bagi tepi bulat, luas muka, kedudukan titik, jarak dan sudut dua item, jumlah beberapa tepi atau muka. Satu klik menyalinnya.", "prefs.render_previous_geometry_budget": "Gunakan bajet geometri pratonton Render sebelumnya", "prefs.render_previous_geometry_budget_help": "Pulihkan had geometri tetap sebelumnya (512 MiB pada desktop 64-bit, 128 MiB di tempat lain) dan bukannya bajet berdasarkan peranti.", "prefs.reports_saved": "{n} disimpan", @@ -2029,6 +2296,8 @@ "prefs.sketch_tab_always_note": "Tab Lakaran kekal dalam reben, sejurus selepas Model, selagi tiada lakaran dibuka. Alat lukisannya kemudian memulakan lakaran pada satah atau muka yang dipilih, atau pada satah yang anda pilih seterusnya, dengan alat itu sedia di dalamnya.", "prefs.sketch_tracking_guides": "Panduan penjajaran pusat dan titik tengah", "prefs.sketch_tracking_guides_note": "Halakan kursor pada pusat bulatan atau titik tengah tepi lurus, kemudian gerakkan secara mendatar atau menegak untuk memaparkan panduan bertitik sementara.", + "prefs.sketch_tracking_points": "Panduan penjajaran daripada titik hujung dan sudut semasa melukis", + "prefs.sketch_tracking_points_note": "Semasa alat lukisan menunggu satu titik, meletakkan penuding pada hujung garis atau lengkok, sudut segi empat tepat atau titik bebas memaparkan panduan bertitik sementara yang sama seperti bagi pusat atau titik tengah. Mati: hanya pusat dan titik tengah.", "prefs.sketching": "Melakar", "prefs.snap_drawing_note": "Semasa melukis atau menyeret titik lakaran.", "prefs.source": "Sumber", @@ -2056,6 +2325,16 @@ "prefs.theme_follow_off": "Mati", "prefs.theme_follow_stop": "Berhenti mengikuti", "prefs.theme_import_hint": "Klik untuk memilih fail tema, seret satu ke sini, atau tekan Ctrl+V (Cmd+V pada Mac) dengan tema yang telah disalin", + "prefs.theme_option_opacity": "Kelegapan", + "prefs.theme_option_opacity_help": "Laraskan kelegapan panel terapung. Paras kebolehbacaan minimum melindungi teks di atas warna tetingkap pandangan.", + "prefs.theme_option_tab_colours": "Warna tab", + "prefs.theme_option_tab_colours_help": "Gunakan warna panel dan tetingkap pandangan tema ini bagi setiap tab reben.", + "prefs.theme_option_tab_fade_ms": "Peralihan tab", + "prefs.theme_option_tab_fade_ms_help": "Pudarkan warna panel dan tetingkap pandangan apabila bertukar tab. Sifar bertukar serta-merta.", + "prefs.theme_options_badge": "Pilihan", + "prefs.theme_options_more": "Lagi · {count}", + "prefs.theme_options_reset": "Set Semula", + "prefs.theme_options_unavailable": "Tidak tersedia pada peranti ini", "prefs.theme_paste": "Tampal tema", "prefs.theme_paste_none": "Papan keratan tidak mengandungi tema SolidCraft", "prefs.timeline_half_hour": "−30 min", @@ -2077,6 +2356,8 @@ "prefs.under_cursor": "Di bawah kursor", "prefs.units_precision": "Unit dan ketepatan", "prefs.updates": "Kemas kini", + "prefs.value_boxes_in_view": "Papar nilai di sebelah pemegangnya dalam pandangan", + "prefs.value_boxes_in_view_tip": "Nilai sesuatu arahan (Jarak, Jejari…) dalam kotak di sebelah pemegangnya, di atas model. Mati: anda menaipnya dalam tetingkap arahan, yang mengambil papan kekunci selepas satu pilihan, dan tiada apa-apa menutupi model.", "prefs.view": "Kelakuan pandangan", "prefs.view_confirm_corner": "OK dan Batal di sudut pandangan", "prefs.view_confirm_corner_note": "Semasa dialog atau lakaran dibuka, tanda semak dan tanda silang di bahagian kanan atas pandangan melakukan apa yang dilakukan OK dan Batal dialog, atau menyiapkan dan membatalkan lakaran. Navigator pandangan bergerak ke bawah di bawahnya.", @@ -2086,6 +2367,12 @@ "prefs.viewport_background": "Latar belakang", "prefs.viewport_background_colour": "Warna", "prefs.viewport_background_theme": "Kecerunan tema", + "prefs.viewport_section_status": "Papar status keratan tetingkap pandangan", + "prefs.viewport_section_status_help": "Papar tetapan keratan dalam tetingkap pandangan. Escape dan butang reben yang aktif mematikannya. Matikan untuk mengekalkan kawalan keratan sebelumnya.", + "prefs.viewport_status_details": "Butiran tetingkap pandangan langsung", + "prefs.viewport_status_details_help": "Sunting tetapan analisis dan keratan yang aktif dalam kad butiran piawai. Matikan untuk memulihkan kekunci analisis baca sahaja dan dialog Keratan.", + "prefs.viewport_status_summary": "Ringkaskan item status tetingkap pandangan", + "prefs.viewport_status_summary_help": "Ringkaskan tiga atau lebih item status tetingkap pandangan ke dalam satu lencana ringkasan. Matikan untuk mengekalkan setiap item dalam baris penuh.", "prefs.wheel": "Roda", "prefs.wheel_trackpad": "Roda dan pad jejak", "prefs.workspace": "Ruang kerja", @@ -2964,7 +3251,9 @@ "render.output_ready": "Sedia untuk merender", "render.output_run_check": "Jalankan semakan", "render.output_size_limit": "{method}: sehingga {dimension} px setiap sisi dan {pixels} piksel. Saiz terbesar yang muat: {width} × {height}.", + "render.output_size_method_profile": "Untuk merender {width} × {height}, pilih {method} dengan profil sumber CPU {profile}, atau saiz yang lebih kecil.", "render.output_size_ok": "Saiz OK · {width} × {height} px", + "render.output_size_profile": "Untuk merender {width} × {height}, pilih profil sumber CPU {profile}, atau saiz yang lebih kecil.", "render.output_swap_orientation": "Tukar lebar dan ketinggian", "render.output_tools_inspector": "Sunting saiz dan format dalam pemeriksa Keluaran.", "render.output_view_image": "Lihat imej", @@ -3177,6 +3466,7 @@ "render.size_custom": "Tersuai…", "render.size_full_hd": "HD Penuh", "render.size_hd": "HD", + "render.size_needs_profile": "{size} · memerlukan {profile}", "render.size_presets": "Pratetap saiz", "render.size_presets_pending": "Akan hadir bersama penyunting Keluaran", "render.size_preview": "Pratonton", @@ -3464,6 +3754,24 @@ "sheet.flange_type_contour": "Kontur", "sheet.flange_type_edge": "Tepi", "sheet.no_body": "tiada jasad logam kepingan: mulakan satu dengan Bebibir > Asas, atau tukarkan plat kepada logam kepingan dengan Tukar kepada Logam Kepingan (pilih muka rata yang besar)", + "sheet.personal_rules.confirm_delete": "Sahkan pemadaman", + "sheet.personal_rules.delete": "Padam", + "sheet.personal_rules.delete_prompt": "Padam peraturan peribadi ini? Peraturan yang sudah ada dalam reka bentuk tidak berubah.", + "sheet.personal_rules.full": "Koleksi peribadi sudah mengandungi 64 peraturan.", + "sheet.personal_rules.invalid_name": "Masukkan nama pendek tanpa pemisah baris atau aksara kawalan.", + "sheet.personal_rules.invalid_values": "Lengkapkan nilai peraturan: setiap satu mesti dapat dikira dalam reka bentuk ini, dengan ketebalan positif dan faktor K dari 0 hingga 1.", + "sheet.personal_rules.missing": "Peraturan peribadi yang dipilih tidak lagi tersedia.", + "sheet.personal_rules.name": "Nama peraturan", + "sheet.personal_rules.name_exists": "Peraturan peribadi dengan nama ini sudah wujud. Gunakan nama lain.", + "sheet.personal_rules.name_hint": "Nama yang unik", + "sheet.personal_rules.overwrite": "Simpan perubahan pada peraturan peribadi yang dipilih", + "sheet.personal_rules.rename": "Tukar nama", + "sheet.personal_rules.rule": "Peraturan peribadi", + "sheet.personal_rules.save": "Simpan baharu", + "sheet.personal_rules.scope": "Disimpan dalam profil anda untuk semua reka bentuk.", + "sheet.personal_rules.select": "Pilih peraturan", + "sheet.personal_rules.title": "Peraturan peribadi", + "sheet.personal_rules.unsupported": "Peraturan ini mempunyai nilai yang tidak dapat digunakan oleh versi ini.", "shortcut.json_error": "parameter mestilah JSON: {error}", "shortcut.no_command": "tiada arahan `{id}`; teks sebelum JSON mestilah ID arahan", "shortcut.pin": "Semat di atas kotak S", @@ -3500,6 +3808,7 @@ "sketch_text.align_right": "Kanan", "sketch_text.bold": "Tebal", "sketch_text.builtin_font": "{family} (terbina dalam)", + "sketch_text.formula": "Mulakan dengan = untuk membina perkataan daripada parameter: ='W' + number(Width)", "sketch_text.italic": "Condong", "sketch_text.loading_fonts": "Mencari fon yang dipasang…", "sketch_text.on_path": "Teks mengikut laluan lengkung, yang menentukan kedudukannya", @@ -3640,6 +3949,8 @@ "ui.3d_mouse_section": "Tetikus 3D", "ui.3d_mouse_show_in_view": "Tunjukkan tetikus 3D dalam paparan 3D", "ui.3d_mouse_show_in_view_tip": "Gambar tetikus 3D anda di sudut kiri bawah, yang bergerak apabila anda menggerakkannya.", + "ui.3d_mouse_sketch_orbit": "Putar lakaran dengan tetikus 3D", + "ui.3d_mouse_sketch_orbit_tip": "Tetikus 3D juga memutar pandangan semasa lakaran dibuka dan dilihat tepat dari hadapan, sama seperti yang boleh dilakukan tetikus. Mati: ia menyorot dan menzum lakaran sehingga anda mencondongkan pandangan.", "ui.3d_mouse_skip": "Peranti saya tidak boleh buat ini", "ui.3d_mouse_skip_tip": "Langkau gerakan ini kedua-dua arah; penentukuran tidak menggunakannya.", "ui.3d_mouse_speed": "Kelajuan tetikus 3D", @@ -3708,6 +4019,7 @@ "ui.agent_chat_auth_terminal": "Jalankan arahan ini dalam terminal, kemudian tekan Sambung semula:", "ui.agent_chat_auth_title": "Log masuk ke ejen", "ui.agent_chat_cancel": "Batal", + "ui.agent_chat_cannot_start": "Terpasang, tetapi tidak bermula: {error}", "ui.agent_chat_changes": "{n} perubahan, satu Buat Asal", "ui.agent_chat_changes_n": "{n} perubahan", "ui.agent_chat_changes_of": "{n}/{limit} perubahan", @@ -3800,9 +4112,12 @@ "ui.agent_chat_queued_stopped": "Menunggu (dihentikan, belum dihantar)", "ui.agent_chat_refresh": "Muat Semula", "ui.agent_chat_refused": "Tindakan ejen tidak dibenarkan: {reason}", + "ui.agent_chat_reinstall": "{agent} terpasang ({program}), tetapi tidak bermula apabila SolidCraft memeriksanya: {error}\nPemasangannya mungkin tidak lengkap, atau dibuat untuk jenis komputer lain. Pasang semula dengan `{install}`, semak bahawa ia mencetak versinya dalam terminal, kemudian tekan Muat Semula.", "ui.agent_chat_remove": "Buang daripada baris gilir", "ui.agent_chat_remove_file": "Buang daripada mesej ini", "ui.agent_chat_remove_imported": "Buang daripada mesej ini; bahagian kekal dalam reka bentuk (Buat Asal membuangnya)", + "ui.agent_chat_reply_cut_off": "Balasan mencapai had panjangnya sebelum selesai, jadi mungkin tidak lengkap. Minta ejen meneruskan, atau memberi jawapan yang lebih pendek.", + "ui.agent_chat_reply_declined": "Model enggan menjawab mesej ini.", "ui.agent_chat_retry": "Cuba semula", "ui.agent_chat_right": "Kanan", "ui.agent_chat_save": "Simpan", @@ -3861,12 +4176,17 @@ "ui.ai_copy_suffix": "salinan", "ui.ai_custom_url": "URL tersuai", "ui.ai_default": "Lalai", + "ui.ai_edit": "Sunting", + "ui.ai_edit_agent_title": "Sunting ejen", + "ui.ai_edit_gone": "Ejen ini telah dibuang, jadi tiada apa-apa untuk disimpan.", + "ui.ai_edit_tip": "Tukar nama, arahan atau titik akhir ejen ini. Kunci yang disimpan kekal tersembunyi.", "ui.ai_http_busy": "titik akhir sibuk atau telah melebihi had; cuba lagi kemudian", "ui.ai_http_key": "kunci ditolak oleh titik akhir: semak dalam Tetapan > AI", "ui.ai_http_not_found": "titik akhir atau model tidak ditemui: semak URL asas dan nama model", "ui.ai_http_too_large": "perbualan terlalu besar untuk titik akhir ini; mulakan sembang baharu", "ui.ai_key": "Kunci API", "ui.ai_key_hint": "tidak wajib; disimpan dalam rantai kunci sistem", + "ui.ai_key_keep_hint": "biarkan kosong untuk mengekalkan kunci yang disimpan", "ui.ai_key_needs_https": "Kunci hanya dihantar melalui HTTPS, atau ke komputer ini.", "ui.ai_key_note": "Disimpan dalam rantai kunci sistem, bukan dalam tetapan SolidCraft. Biarkan kosong untuk pelayan setempat.", "ui.ai_keychain_error": "Rantai kunci sistem menolak: {error}", @@ -3898,6 +4218,7 @@ "ui.ai_request_timeout": "Had masa permintaan", "ui.ai_request_timeout_hint": "Berapa lama satu permintaan model ke titik akhir ini boleh mengambil masa, daripada 30 s hingga sejam. Naikkan untuk model setempat yang perlahan.", "ui.ai_reset": "Set Semula", + "ui.ai_save": "Simpan", "ui.ai_settings": "Tetapan AI…", "ui.ai_show_features": "Papar ciri AI", "ui.ai_show_features_note": "Panel sembang untuk ejen pengekodan atau titik akhir model pilihan mengubah reka bentuk terbuka. Jika dimatikan: tiada butang AI, pintasan, menu atau panel; tiada ejen dicari atau dimulakan.", @@ -4071,6 +4392,40 @@ "ui.design_properties.project": "Projek", "ui.design_properties.revision": "Semakan", "ui.design_saved": "Disimpan ke {path}", + "ui.details_access_caption": "Muka menunjukkan sama ada alat dapat mencapainya dari arah yang dipilih.", + "ui.details_angle": "Sudut", + "ui.details_continuity_caption": "Sunting sambungan yang dipilih. Sambungan lain mengekalkan tetapannya sendiri.", + "ui.details_continuity_stale": "Geometri terdahulu: jalankan semula analisis untuk menyunting atau membingkaikan sambungan ini.", + "ui.details_copy_list": "Salin senarai", + "ui.details_curvature": "Toleransi kelengkungan", + "ui.details_curvature_caption": "Muka diwarnakan mengikut jejari kelengkungan setempatnya.", + "ui.details_direction": "Arah alat", + "ui.details_draft_caption": "Muka diwarnakan mengikut sudutnya terhadap arah tarikan.", + "ui.details_edit_seam": "Sunting sambungan", + "ui.details_fixture": "Spesimen komponen — data sampel sahaja", + "ui.details_fixture_caption": "Spesimen komponen dengan nilai ilustrasi; ini bukan hasil model.", + "ui.details_fixture_problem": "Contoh penemuan dengan masalah", + "ui.details_fixture_readout": "Contoh nilai yang diukur", + "ui.details_flip": "Balik", + "ui.details_gap": "Sela", + "ui.details_hatch": "Lorek muka yang dipotong", + "ui.details_jump": "Lonjakan kelengkungan", + "ui.details_key": "Petunjuk", + "ui.details_offset": "Ofset", + "ui.details_plane": "Satah", + "ui.details_pull": "Arah tarikan", + "ui.details_radius": "Jejari", + "ui.details_readout": "Bacaan", + "ui.details_samples": "Sampel", + "ui.details_seam": "{body} · Tepi {edge}", + "ui.details_settings": "Tetapan", + "ui.details_sharp_gap": "Tajam atau sela", + "ui.details_show_all": "Papar semua {count}", + "ui.details_smooth": "Licin", + "ui.details_stripes": "Jalur", + "ui.details_target": "Bingkaikan geometri", + "ui.details_worst_seams": "Sambungan terburuk", + "ui.details_zebra_caption": "Jalur yang dipantulkan menunjukkan kehalusan pertemuan muka.", "ui.diameter": "Diameter", "ui.dimension": "Dimensi", "ui.direct_interaction": "Interaksi langsung", @@ -4131,6 +4486,7 @@ "ui.equation_y_of_x": "y(x)", "ui.equivalent_radius": "Jejari Setara", "ui.expand": "Kembangkan", + "ui.explode_needs_two_components": "Pandangan Terlerai menarik komponen berjauhan, jadi reka bentuk memerlukan dua atau lebih komponen (Komponen Baharu, Sisip Reka Bentuk atau Cipta Komponen daripada Jasad)", "ui.exportDxf": "Eksport DXF…", "ui.export_ellipsis_keymap": "Eksport…", "ui.export_keymap_tip": "Simpan kekunci, kegemaran kotak S dan bar alat ke fail", @@ -4419,12 +4775,17 @@ "ui.plugins_skipped": "{count} folder tidak dapat dibaca.", "ui.plugins_version": "{version}", "ui.point": "Titik", + "ui.point_cloud_add_filter": "Tambah penapis…", "ui.point_cloud_auto_range": "Julat Automatik", "ui.point_cloud_color_by": "Warna Mengikut", "ui.point_cloud_file_colors": "Warna Fail", + "ui.point_cloud_filter_by": "Tapis mengikut", "ui.point_cloud_height_high": "Tertinggi", "ui.point_cloud_height_low": "Terendah", + "ui.point_cloud_legend_filter": "Dipaparkan dan disembunyikan dalam petunjuk di atas", "ui.point_cloud_no_file_colors": "Fail tiada warna", + "ui.point_cloud_pause": "Jeda", + "ui.point_cloud_play": "Main", "ui.point_cloud_single_color": "Warna Tunggal", "ui.point_cloud_size": "Saiz Titik", "ui.point_cloud_units": "Unit Fail", @@ -4453,6 +4814,7 @@ "ui.properties.inertia_y": "Iyx, Iyy, Iyz", "ui.properties.inertia_z": "Izx, Izy, Izz", "ui.properties.measuring": "Mengukur…", + "ui.properties.measuring_bodies": "Mengukur… {done} daripada {total} jasad", "ui.properties.measuring_failed": "Pengukuran berhenti sebelum selesai.", "ui.properties.principal_axis": "Paksi utama {number}", "ui.properties.principal_moments": "Momen utama", @@ -4520,6 +4882,7 @@ "ui.selectOther": "Pilih Lain…", "ui.selectTangentChain": "Pilih Rantaian Tangen", "ui.select_all": "Pilih Semua", + "ui.select_components_by_face": "Pilih komponen (satu muka memilih komponennya)", "ui.selection": "Pilihan", "ui.selection_modifiers": "Pengubah suai pilihan", "ui.selection_shift_add_only": "Shift+klik menambah tanpa membuang", @@ -4561,6 +4924,11 @@ "ui.sketch_intersect_selection_filter": "Penapis Pilihan", "ui.sketch_modifiers": "Pengubah suai lakaran", "ui.sketch_project_geometry": "Geometri", + "ui.sketch_report.all_constrained": "Semuanya terkekang sepenuhnya.", + "ui.sketch_report.dof": "Darjah kebebasan", + "ui.sketch_report.loose": "Tidak terkekang sepenuhnya", + "ui.sketch_report.status": "Status", + "ui.sketch_report.title": "Laporan Lakaran", "ui.sketch_scale": "Skala Lakaran", "ui.sketches": "Lakaran", "ui.slice": "Hiris", @@ -4670,7 +5038,14 @@ "ui.viewport_status_key": "Papar petunjuk untuk {name}", "ui.viewport_status_minimum_radius": "Jejari minimum", "ui.viewport_status_pairs": "{count} pasangan", + "ui.viewport_status_section_hatch": "{setting} · lorek", + "ui.viewport_status_section_plane": "Satah semasa", + "ui.viewport_status_section_tilted": "condong · {offset}", "ui.viewport_status_stripes": "{count} jalur", + "ui.viewport_status_summary_clear": "Matikan semuanya", + "ui.viewport_status_summary_heading": "Dalam pandangan ini", + "ui.viewport_status_summary_label": "{name} dan {count} item lagi", + "ui.viewport_status_summary_more": "+{count}", "ui.viewport_theme_follow": "Ikut tema", "ui.visual_shaded_hidden_edges": "Berlorek dengan tepi tersembunyi", "ui.visual_shaded_with_edges": "Berlorek dengan tepi", diff --git a/assets/locales/nb.json b/assets/locales/nb.json index ab93a6f8a4..c7de8432d0 100644 --- a/assets/locales/nb.json +++ b/assets/locales/nb.json @@ -60,6 +60,7 @@ "BLEND SURFACE": "OVERGANGSFLATE", "BOUNDARY SURFACE": "GRENSEFLATE", "BROWSER": "MODELLTRE", + "Back to the original order": "Tilbake til opprinnelig rekkefølge", "Ball": "Kule", "Bar": "Stang", "Base Center": "Basesenter", @@ -595,6 +596,8 @@ "Snap to face and edge centres": "Fang til flate- og kantsentre", "SolidCraft cannot submit reports directly. Submit on GitHub; no token is needed in the app.": "SolidCraft kan ikke sende inn rapporter direkte. Send inn på GitHub; ingen token trengs i appen.", "SolidCraft closed before these designs were saved. Their last autosave can be opened.": "SolidCraft ble lukket før disse designene ble lagret. Du kan åpne den siste automatisk lagrede versjonen av hvert design.", + "Sort A to Z": "Sorter A til Å", + "Sort Z to A": "Sorter Å til A", "Spacing 2 must be a non-zero number": "Avstand 2 må være et tall forskjellig fra null", "Spacing must be a non-zero number": "Avstand må være et tall forskjellig fra null", "Specialise": "Spesialiser", @@ -819,6 +822,7 @@ "cmd.appearance_pick_up": "Plukk opp utseende", "cmd.appearance_replace": "Erstatt utseende", "cmd.assembly_bom": "Stykkliste", + "cmd.assembly_mass_properties": "Masseegenskaper", "cmd.body.print_face": "Utskriftsflate", "cmd.body_rename": "Gi nytt navn til kropp", "cmd.browser_collapse_all": "Skjul alle", @@ -1244,6 +1248,7 @@ "cmd.view.render_browser": "Rendringsutforsker", "cmd.view.render_dock": "Flytt rendringspanel", "cmd.view.render_focus": "Rendringsfokusvisning", + "cmd.view.status_details": "Statusdetaljer i visningsområdet", "cmd.view.status_items": "Statuselementer i visningsområdet", "cmd.view_ambient_occlusion": "Ambient occlusion", "cmd.view_angle_readout": "Visningsvinkler", @@ -1387,21 +1392,168 @@ "from the rule": "fra regelen", "help.apache": "Apache-lisens 2.0", "help.appearance.assign": "Gir de valgte kroppene, flatene eller komponentene en farge eller et materialutseende, uten å endre vekten deres. Bruk det til å skille deler fra hverandre eller til å forhåndsvise en overflatebehandling.", + "help.assembly.bom": "Lister opp delene i designet eller en komponent med antall, gruppert etter delenummer, og kan lagre listen som CSV eller JSON. Bruk det som stykkliste ved bestilling og montering.", "help.attributions": "Opphav og lisenser (ATTRIBUTION.md)", "help.build": "Versjon {version} · commit {commit} · bygget {date}", + "help.canvas.decal": "Plasserer et bilde på en plan flate av modellen, som et klistremerke. Bruk det til å vise en etikett, en logo eller et trykt mønster på en del.", + "help.canvas.edit": "Endrer et lerret: posisjon, størrelse, vinkel, opasitet, speiling, synlighet eller navn. Bruk det til å justere et referansebilde mot skissen du tegner over det.", + "help.canvas.insert": "Plasserer et PNG- eller JPEG-bilde på et plan eller en flate som en gjennomsiktig referanse. Bruk det til å tegne av et foto eller en tegning i en skisse, med bildet skalert til en kjent størrelse.", + "help.component.break_link": "Løsner en koblet komponent fra designfilen sin og beholder kroppene som de er. Bruk det når delen ikke lenger skal følge endringer i originalfilen.", + "help.component.capture_position": "Beholder posisjonene til delene du har flyttet for hånd, slik at de blir der delene står. Bruk det etter at du har dratt deler på plass som ikke holdes av noe ledd.", + "help.component.create": "Legger til en ny, tom komponent i designet, inni den aktive komponenten med mindre du velger en annen overordnet komponent. Bruk det til å gi en ny del av en sammenstilling sin egen plass for kropper og skisser.", "help.component.finish_edit": "Lagrer delen du redigerer på stedet, går tilbake til sammenstillingen og oppdaterer sammenstillingen fra den. Bruk den når endringene dine i den koblede delen er ferdige.", + "help.component.from_bodies": "Gjør hver valgte kropp til en egen komponent, oppkalt etter kroppen. Bruk det når et design bygget av løse kropper skal bli en sammenstilling der delene kan kobles sammen med ledd og flyttes.", + "help.component.insert_design": "Setter inn et annet lagret design som en komponent koblet til filen sin, med volumkroppene plassert som de står der. Bruk det til å gjenbruke en del i en sammenstilling og få med senere endringer med Oppdater koblinger.", + "help.component.properties": "Viser volum, masse, massesenter og treghetsmomenter for en komponent, ut fra materialene til kroppene. Bruk det til å sjekke vekt og balanse i en sammenstilling.", + "help.component.revert_position": "Setter delene du har flyttet for hånd tilbake dit de var, og forkaster flyttinger som ikke ble registrert. Bruk det til å angre prøveflyttinger i en sammenstilling.", + "help.component.update_links": "Leser de koblede designfilene på nytt og erstatter kropper som er endret. Bruk det etter at du har redigert en del som denne sammenstillingen bruker.", + "help.config.configure": "Legger til parametere, funksjoner, materialer eller utseender som kolonner i konfigurasjonstabellen. Bruk det til å bygge varianter av ett design, for eksempel størrelser av en del.", + "help.config.preview": "Sjekker at en rad i konfigurasjonstabellen kan bygges på nytt, uten å endre designet eller historikken. Bruk det før du bytter til en variant du er usikker på.", + "help.config.rules": "Viser reglene i konfigurasjonstabellen, radene og hvilken rad som er aktiv. Bruk det til å se hvordan variantene av et design er satt opp.", + "help.config.table": "Viser konfigurasjonstabellen: én rad per variant, én kolonne per parameter eller funksjon, og om designet fortsatt samsvarer med den aktive raden. Bruk det til å gå gjennom variantene av et design.", "help.connect_note": "Start hver assistent på nytt for å laste inn SolidCraft-serveren. Hver fil ble sikkerhetskopiert som .bak før den ble endret.", "help.connect_title": "Koble til AI-assistenter", + "help.construct.axis.edge": "Oppretter en hjelpeakse langs en rett kant eller skisselinje. Bruk den som akse for en omdreining, et sirkulært mønster eller et ledd.", + "help.construct.axis.normal_to_face": "Oppretter en hjelpeakse gjennom et punkt, vinkelrett på en flate. Bruk den som en akse som står rett ut fra en overflate.", + "help.construct.axis.through_cylinder": "Oppretter en hjelpeakse gjennom senteret av en sylinder-, kjegle- eller torusflate, eller en sirkulær kant. Bruk den til å dreie funksjoner eller lage mønster av dem rundt en rund del.", + "help.construct.axis.two_planes": "Oppretter en hjelpeakse der to plan eller plane flater møtes. Bruk den når aksen du trenger er linjen mellom to flater.", + "help.construct.axis.two_points": "Oppretter en hjelpeakse gjennom to punkter, hjørner eller skissepunkter. Bruk den til en akse mellom to kjente posisjoner.", + "help.construct.plane.along_path": "Oppretter et hjelpeplan vinkelrett på en kant eller kurve, på en posisjon langs den. Bruk det til å skisse en profil for en sweep eller et snitt av en bane.", + "help.construct.plane.at_angle": "Oppretter et hjelpeplan vridd med en vinkel fra et plan eller en flate, om en akse eller kant. Bruk det til skråstilte skisser, for eksempel en vinklet brakett.", + "help.construct.plane.midplane": "Oppretter et hjelpeplan midt mellom to plan eller plane flater, eller som halverer dem der de møtes i en vinkel. Bruk det som symmetriplan for speiling.", + "help.construct.plane.offset": "Oppretter et hjelpeplan parallelt med et plan eller en plan flate, i en avstand eller gjennom et punkt. Bruk det til å skisse i en bestemt høyde over en flate.", + "help.construct.plane.perpendicular": "Oppretter et hjelpeplan som inneholder en linje eller kant og står vinkelrett på et plan eller en flate. Bruk det til å skisse oppreist på en modell langs en kant.", + "help.construct.plane.tangent": "Oppretter et hjelpeplan som berører en sylinder, kjegle, kule eller torus i et punkt. Bruk det til å skisse på siden av en rund del.", + "help.construct.plane.three_points": "Oppretter et hjelpeplan gjennom tre punkter. Bruk det til et plan som bestemmes av posisjoner på modellen, for eksempel tre hjørner.", + "help.construct.plane.two_edges": "Oppretter et hjelpeplan gjennom to rette kanter eller linjer som ligger i ett plan. Bruk det til et plan på tvers av en del, for eksempel gjennom to motstående kanter.", + "help.construct.point.along_path": "Oppretter et hjelpepunkt på en posisjon langs en kant eller kurve. Bruk det som referanse et stykke ut langs en bane.", + "help.construct.point.center": "Oppretter et hjelpepunkt i senteret av en sirkulær kant, skissesirkel eller bue, kule eller torus. Bruk det som referanse for hull, akser og ledd.", + "help.construct.point.edge_plane": "Oppretter et hjelpepunkt der en kant eller linje går gjennom et plan eller en plan flate. Bruk det til å markere hvor en bane krysser et plan.", + "help.construct.point.three_planes": "Oppretter et hjelpepunkt der tre plan eller plane flater møtes. Bruk det som hjørnereferanse, selv der modellen ikke har noe hjørne.", + "help.construct.point.two_edges": "Oppretter et hjelpepunkt der to kanter eller linjer møtes, eller kommer nærmest hverandre. Bruk det som referanse i et virtuelt hjørne.", + "help.construct.point.vertex": "Oppretter et hjelpepunkt på et hjørne eller skissepunkt. Bruk det som en referanse som blir stående når skissen eller kroppen rundt er skjult.", + "help.contact.check": "Lister opp parene av deler i kontaktsett som går gjennom hverandre i gjeldende posisjon. Bruk det til å finne kollisjoner før du driver ledd eller kjører en bevegelsesanalyse.", + "help.contact.create": "Grupperer to eller flere deler i et kontaktsett, der kroppene hindres i å gå gjennom hverandre mens leddene beveger seg. Bruk det til deler som skal stoppe når de berører hverandre, for eksempel en spak mot stoppen sin.", + "help.contact.disable_all": "Slår av kontakt, slik at drevne ledd og bevegelsesanalyser ikke lenger stopper der delene berører hverandre. Bruk det til å flytte delene fritt igjen, eller til å holde en stor sammenstilling responsiv.", + "help.contact.edit": "Endrer et kontaktsett: delene det inneholder, navnet eller om det er undertrykt; det kan også slette settet. Bruk det til å justere hvilke deler som hindres i å gå gjennom hverandre.", + "help.contact.enable_all": "Legger alle delene i sammenstillingen i ett kontaktsett, slik at ingen to deler kan gå gjennom hverandre mens leddene beveger seg. Bruk det til en rask kontroll av en hel mekanisme.", + "help.contact.enable_sets": "Slår på kontakt for kontaktsettene du har definert, slik at drevne ledd og bevegelsesanalyser stopper der delene deres ville gått gjennom hverandre. Bruk det til å få tilbake settene dine etter at du har slått av kontakt.", + "help.contact.list": "Lister opp kontaktsettene i designet, delene i hvert av dem og om kontakt er slått på. Bruk det til å sjekke hvordan kontakt er satt opp før du driver sammenstillingen.", + "help.document.inspect": "Viser en oversikt over designet: komponenter, kropper, skisser, funksjoner og parametere. Bruk det til å få oversikt i et design du ikke har bygget selv.", + "help.document.units": "Endrer enhetene designet viser lengder i, for eksempel mm eller tommer. Geometrien beholder størrelsen sin; bare visningen og vanlige tall følger med.", + "help.edit.checkpoint": "Husker designet, den aktive skissen og angrehistorikken slik de er nå. Bruk det før du prøver noe risikabelt som du kanskje vil gå tilbake fra.", + "help.edit.restore_checkpoint": "Går tilbake til et sjekkpunkt, inkludert angrehistorikken. Bruk det til å forkaste alt som er gjort siden sjekkpunktet, i ett trinn.", + "help.explode.create": "Oppretter en eksplodert visning som skyver delene bort fra sentrum av sammenstillingen, eller etter flyttingene du angir. Bruk det til å vise hvordan en sammenstilling settes sammen, i veiledninger eller presentasjoner.", "help.face.delete": "Fjerner de valgte flatene og lar naboflatene lukke åpningen, eller lar åpningen stå igjen som en flate. Bruk det til å fjerne en avrundet kant, et hull eller en forhøyning som er i veien.", + "help.file.insert_mesh": "Legger en 3MF-, STL-, OBJ-, PLY- eller glTF-fil til i designet som nettkropper i en ny komponent. Bruk det til å plassere en nedlastet eller skannet modell ved siden av designet ditt som referanse.", + "help.file.insert_step": "Legger en STEP-, IGES-, FreeCAD- eller Rhino-fil til i designet som volumkropper i en ny komponent; delene i en sammenstilling blir nestede komponenter. Bruk det til å hente inn modeller fra leverandører eller andre CAD-programmer.", + "help.file.save_mesh": "Lagrer kropper som et STL-, 3MF- eller OBJ-mesh, med den jevnheten på krumme flater du velger. Bruk det til å forberede deler for 3D-printing eller for programmer som leser mesher.", + "help.file.send_to_slicer": "Lagrer de valgte kroppene som en 3MF-fil og åpner den i slicer-programmet du velger. Bruk det til å gå fra modell til utskrift i ett trinn.", "help.font_details": "Grensesnittet bruker de innebygde skriftene i egui (Ubuntu Light og Hack, under Ubuntu Font Licence og MIT/Bitstream Vera-lisensen; Noto Emoji og skriften med emoji-ikoner, under OFL og MIT). Skissetekst bruker Liberation Sans (SIL OFL 1.1). Crafting Apps sin valgfrie skriftpakke, craft-fonts (BIZ UDPGothic, Shippori Mincho, BIZ UDMincho, Noto Sans CJK SC, Noto Sans Arabic; SIL OFL 1.1), følger ikke med i SolidCraft.", "help.fonts": "Skrifter", + "help.frame.member": "Plasserer en profil, for eksempel et rør eller en aluminiumsprofil, langs linjene i en rammeskisse, ett element per linje. Bruk det til å bygge rammer, stativer og sveisede konstruksjoner.", + "help.inspect.accessibility": "Fargelegger flatene et verktøy kan nå fra én retning, og de det ikke kan nå. Bruk det til å sjekke en del for maskinering eller støping fra den siden.", + "help.inspect.center_of_mass": "Viser massesenteret til kropper, hver for seg og alle samlet, ved jevn tetthet. Bruk det til å sjekke balansen og hvor en del vil velte.", + "help.inspect.colored_components": "Gir hver komponent sin egen midlertidige farge, uten å endre utseender. Bruk det til å se forskjell på delene i en sammenstilling med et blikk.", + "help.inspect.compare": "Sammenligner to kropper og viser hvor den ene har materiale som den andre ikke har, med volum og avvik. Bruk det til å sjekke en endret eller importert del mot originalen.", + "help.inspect.continuity": "Sjekker hvor jevnt flater møtes langs kantene sine: mellomrom, knekk eller krumningssprang, og fargelegger skjøtene. Bruk det til å finne hvor flater ikke går rent sammen.", + "help.inspect.curvature_comb": "Tegner kammer langs kurver, kanter eller flater, der tennene viser hvor sterkt de bøyer. Bruk det til å oppdage flate partier, knekk og ujevn krumning.", + "help.inspect.curvature_map": "Fargelegger flater etter hvor sterkt de krummer, rødt der radiusen er liten. Bruk det til å finne trange partier og vurdere hvor jevn en flate er.", + "help.inspect.draft": "Fargelegger flater etter slippvinkelen til en trekkretning og markerer de som har for liten. Bruk det til å sjekke en del før støping.", + "help.inspect.environment_map": "Viser modellen med en reflekterende, speillignende finish. Bruk det til å vurdere hvordan flatene flyter, ut fra hvordan refleksjonene løper over dem.", + "help.inspect.interference": "Finner kropper som overlapper og viser hver overlapping med volum. Bruk det til å sjekke en sammenstilling for deler som kolliderer.", + "help.inspect.isocurve": "Tegner de isoparametriske kurvene på flater, rutenettet flaten er bygget på. Bruk det til å se hvordan en flate flyter og hvor kurvene samler seg tett.", + "help.inspect.measure": "Måler lengder, avstander, vinkler, areal og radier mellom det du velger, eller volumet og massen til kropper. Bruk det til å sjekke størrelser og klaringer.", + "help.inspect.minimum_radius": "Finner den minste krumningsradiusen til kurver, kanter eller flater, og hvor den er. Bruk det til å sjekke at et verktøy eller en dyse kan følge formen.", + "help.inspect.section": "Skjærer visningen åpen langs et plan og skraverer snittflatene, uten å endre modellen. Bruk det til å se inn i deler og sammenstillinger.", + "help.inspect.zebra": "Viser svarte og hvite striper reflektert på flatene til modellen. Bruk det til å vurdere jevnheten: striper som brytes eller knekker ved en kant, viser en dårlig overgang.", + "help.joint.as_built": "Forbinder to deler der de allerede står, uten å flytte noen av dem, og angir hvordan de fortsatt kan bevege seg. Bruk det til deler du allerede har plassert i sluttposisjonen.", + "help.joint.cam": "Kobler en dreiende kam til en følger, slik at følgeren glir eller svinger når kamprofilen passerer under den. Bruk det til kammer, eksentre og ventildrifter.", + "help.joint.create": "Forbinder to deler ved leddorigoene du velger og flytter den andre delen på plass; leddtypen bestemmer hvordan de fortsatt kan bevege seg. Bruk det til å bygge en sammenstilling der delene beveger seg som den virkelige mekanismen.", + "help.joint.drive": "Setter vinkelen eller avstanden til et ledd og flytter delene med det, innenfor grensene; koblede ledd og lukkede sløyfer følger med. Bruk det til å sjekke at en mekanisme beveger seg som tenkt.", + "help.joint.gear": "Kobler to dreiende ledd med et utvekslingsforhold, slik at drift av det ene dreier det andre. Bruk det til rette tannhjul og ringhjul, med forholdet fra delesirkelradier eller tannantall.", + "help.joint.limits": "Setter hvor langt et ledd kan dreie eller gli, som minimum og maksimum, eller fjerner grensene. Bruk det til å holde et hengsel, et glidende ledd eller en spak innenfor det virkelige bevegelsesområdet.", + "help.joint.motion_link": "Kobler to ledd med et forhold og en forskyvning, slik at drift av det ene flytter det andre. Bruk det til en fast sammenheng mellom ledd som Tannhjul, Tannstang og drev eller Skrue ikke dekker.", + "help.joint.origin": "Plasserer en navngitt leddorigo på en del: i senteret av en flate eller rund ende, midt på en kant, i et hjørne eller i et punkt. Bruk det til å forberede hvor ledd festes; en valgt på en flate følger flaten når delen endrer størrelse.", + "help.joint.rack_pinion": "Kobler et dreiende drev til en glidende tannstang, slik at stangen flytter seg like langt som delesirkelen drevet ruller gjennom. Bruk det til tannstangdrifter, styring og lineære akser.", + "help.joint.reach": "Flytter et punkt på en del mot et mål ved å endre leddene i kjeden, innenfor grensene deres. Bruk det til å stille en mekanisme etter hvor enden skal være, for eksempel spissen på en robotarm.", + "help.joint.rigid_group": "Låser de valgte delene sammen slik de er, så de beveger seg som én. Bruk det til deler som hører sammen og ikke skal bevege seg i forhold til hverandre.", + "help.joint.screw": "Kobler dreining og glidning med en stigning, slik at hver omdreining flytter like langt som stigningen. Bruk det til ledeskruer, bolter og gjengede spindler.", + "help.library.apply": "Bruker et materiale eller utseende fra en lokal bibliotekfil på kropper, flater eller komponenter. Bruk det til å gjenbruke materialer du har lagret fra et annet design.", + "help.library.export": "Lagrer materialene og utseendene i designet til en bibliotekfil. Bruk det til å dele dem eller gjenbruke dem i andre design.", + "help.library.inspect": "Sjekker en bibliotekfil og lister opp materialene og utseendene i den, uten å endre designet. Bruk det til å se hva et bibliotek inneholder før du bruker det.", "help.licences": "Lisenser", "help.material.assign": "Angir hva de valgte kroppene er laget av, for eksempel stål, aluminium eller ABS. Bruk det slik at masse og vekt stemmer med den virkelige delen.", + "help.material.properties.validate": "Sjekker et materialdatakort for konsistente tall og manglende verdier. Bruk det før du stoler på dataene for masse eller stivhet.", + "help.mesh.convert": "Bygger en nettkropp, for eksempel en STL, om til en redigerbar volumkropp med plane og sylindriske flater. Bruk det til å endre importerte mekaniske deler som en modellert kropp.", "help.mit": "MIT-lisens", + "help.motion.study": "Setter leddverdier ved trinn langs en tidslinje og spiller dem av som en animasjon. Bruk det til å se en mekanisme bevege seg gjennom en sekvens og sjekke klaringer underveis.", + "help.motion.trace": "Sporer banen et punkt på en del følger mens en bevegelsesanalyse spilles av eller et ledd drives, og beholder den som en kurve i en 3D-skisse når du bekrefter. Bruk det til å se hvor en mekanisme når, eller til å bygge videre på banen.", "help.notice": "NOTICE", + "help.occurrence.ground": "Fester en del på plass, eller frigjør den igjen, slik at ledd flytter de andre delene rundt den. Bruk det til rammen eller basen resten av sammenstillingen bygges på.", "help.occurrence.move": "Flytter en del i en sammenstilling bare langs frihetsgradene som leddene tillater. Velg en flate på delen, og dra deretter en tilgjengelig pil eller bue.", "help.parameters.change": "Åpner tabellen over navngitte verdier og uttrykk som styrer målene i designet. Bruk den til å endre en størrelse ett sted og la alle funksjoner som bruker den følge med.", + "help.parameters.export": "Lagrer parameterne i designet som CSV eller JSON. Bruk det til å redigere dem i et regneark eller flytte dem til et annet design.", + "help.parameters.graph": "Viser hvilke parametere og funksjoner som bruker hvilke andre, og eventuelle sløyfer eller feil blant dem. Bruk det til å se hva en endring av en parameter vil påvirke.", + "help.parameters.import": "Leser parametere fra CSV eller JSON inn i designet, alle eller ingen. Bruk det til å hente inn verdier fra et regneark eller et annet design.", + "help.parameters.move": "Flytter en brukerparameter før eller etter en annen i listen. Bruk det til å holde relaterte parametere sammen.", + "help.parts.fastener": "Legger til en standard skrue, bolt, mutter, skive, stift eller et lager i størrelsen du velger. Plassert på et hull sitter det i hullet med et ledd, så du slipper å modellere festemidler selv.", + "help.parts.insert": "Setter inn en standarddel i familien, størrelsen og lengden du velger. Plassert på kanten eller veggen av et hull sitter den på hullet med et stivt ledd.", + "help.parts.library": "Åpner standarddelene: skruer, bolter, muttere, skiver, settskruer, passtifter og lagre i standardstørrelser. Bruk det til å legge til en standarddel i stedet for å modellere den selv.", + "help.parts.resize": "Endrer størrelsen eller lengden på en innsatt standarddel og bygger den på nytt; leddene holder den på plass. Bruk det når en skrue viser seg å være for lang eller et hull trenger en annen størrelse.", + "help.plastic.assign_rule": "Tildeler en plastregel til kropper, slik at plastfunksjonene deres bruker slippvinkelen og klaringen fra den som standard. Bruk det til å sette opp en støpt del én gang for alle funksjonene.", + "help.plastic.boss": "Legger til et skruefeste på en flate: en stolpe med valgfritt hull, slippvinkel, avrunding i foten og støttende ribber. Bruk det til skruepunkter i støpte eller utskrevne hus.", + "help.plastic.lip": "Legger til en leppekant eller et tilsvarende spor langs kanten av en uthulet kropp. Bruk det slik at to husdeler plasserer seg mot hverandre og lukker tett.", + "help.plastic.manage_rules": "Oppretter og redigerer plastregler: materiale, veggtykkelse, radier, klaring og slippvinkel. Bruk dem til å holde funksjonene i en støpt del konsistente.", + "help.plastic.rest": "Legger til et anlegg på en flate: en rund eller rektangulær pute, massiv eller hul, med slippvinkel. Bruk det som støtte eller avstandsstykke i et støpt hus.", + "help.plastic.snap_fit": "Legger til en snap-fit-krok på en flate, med lengde, tykkelse og hake. Bruk det til å klikke deler sammen uten skruer.", "help.pointcloud.insert": "Sett inn en PLY-, XYZ- eller PTS-skanning som en punktsky ved siden av modellen. Bruk den som referanse når du bygger en modell fra måledata.", + "help.sheet.convert": "Gjør en plate eller en uthulet kropp med jevn tykkelse om til en platekropp, med bøyninger ved kantene du velger. Bruk det til å gjøre en eksisterende del utbrettbar.", + "help.sheet.export_bend_table": "Lagrer bøyetabellen til en platekropp som CSV eller JSON: vinkler, radier og bøyeposisjoner i utbrettingen. Bruk det til kantpressen eller verkstedet.", + "help.sheet.export_dxf": "Lagrer utbrettingen som en DXF, med konturer, bøyelinjer og valgfrie bøyenotater. Bruk det til laser-, plasma- eller vannskjæring.", + "help.sheet.flange": "Legger til en flens: en grunnplate fra en skisse, en vegg langs en åpen skissekontur eller en bøyd vegg på platekanter. Bruk det til å bygge opp platedeler.", + "help.sheet.flat_pattern": "Oppretter utbrettingen av en platekropp, med størrelse, utsparinger og bøyelinjer. Bruk det til å sjekke emnet før du skjærer det ut.", + "help.sheet.fold": "Bøyer en plate langs en skisselinje med en vinkel og en radius. Bruk det til å brette en flat plate etter at du har lagt utskjæringer i den.", + "help.sheet.hem": "Bretter en platekant tilbake over seg selv. Bruk det til trygge, stive kanter uten skarpe grater.", + "help.sheet.manage_rules": "Oppretter og redigerer plateregler: tykkelse, bøyeradius, K-faktor, avlastninger og mellomrom. Bruk dem til å tilpasse materialet og verktøyene i verkstedet ditt.", + "help.sheet.path_flange": "Legger til en kjede av flenser langs en platekant, etter en åpen kjede av skisselinjer. Bruk det til profiler som en U- eller Z-form i ett trinn.", + "help.sheet.perforate": "Legger til et felt av hull over hele den flate platen, i et forskjøvet eller kvadratisk mønster. Bruk det til ventilasjon, gitter og lettere paneler.", + "help.sheet.refold": "Bretter en utbrettet platekropp sammen igjen. Bruk det etter at du har lagt funksjoner til den flate platen.", + "help.sheet.unfold": "Bretter ut alle bøyninger i en platekropp. Bruk det til å legge til hull eller skjæringer som må krysse en bøyning, og brett deretter sammen igjen.", + "help.sketch.arc.center_point": "Tegner en sirkelbue rundt et senterpunkt, fra et startpunkt til et sluttpunkt. Bruk det når buens senter er kjent, for eksempel en radius rundt et hull eller en dreietapp.", + "help.sketch.arc.tangent": "Tegner en bue som starter ved enden av en linje eller bue og fortsetter tangentielt. Bruk det til å føre en kjede videre med en jevn bøy i stedet for et hjørne.", + "help.sketch.arc.three_point": "Tegner en bue fra et startpunkt til et sluttpunkt gjennom et tredje punkt. Bruk det når buen må gå gjennom gitte punkter i stedet for rundt et kjent senter.", + "help.sketch.arc3d": "Tegner en eksakt sirkelbue i rommet gjennom tre punkter, med 3D-skisse slått på. Bruk det til svinger i baner og føringskurver som forlater skisseplanet.", + "help.sketch.auto_constrain": "Legger til bindingene skissen nesten har fra før, som vannrett, parallell eller sammenfallende, og setter deretter mål på den til den er fullt bestemt. Bruk det til å låse en grovt tegnet skisse i ett trinn.", + "help.sketch.auto_constrain_from_datum": "Fungerer som Automatiske bindinger, men måler posisjonene fra et referansepunkt du velger i stedet for origo. Bruk det når delen er lagt ut fra ett referansepunkt, for eksempel et hjørne eller et hull.", + "help.sketch.blend_curve": "Forbinder endene på to kurver med en jevn spline som er tangent til begge, eller også krumningskontinuerlig hvis du velger det. Bruk det til å bygge bro over et mellomrom mellom kurver uten knekk.", + "help.sketch.break": "Deler en kurve i biter der andre kurver krysser den, eller ved punkter du plasserer på den. Bruk det når en del av en kurve trenger sin egen binding eller skal slettes for seg.", + "help.sketch.cancel": "Forlater skissen og forkaster alt som er endret siden den ble opprettet eller åpnet for redigering. Angre henter endringene tilbake.", + "help.sketch.centerline": "Bytter linjer mellom vanlige linjer og senterlinjer, akser som profiler utelater. Bruk en senterlinje som akse for en omdreining, en speiling eller en symmetribinding.", + "help.sketch.chamfer.distance_angle": "Lager en fas på hjørnet mellom to linjer, bestemt av en avstand langs den første linjen og en vinkel. Bruk det når en tegning oppgir fasen som en lengde og en vinkel, for eksempel 2 mm × 30°.", + "help.sketch.chamfer.equal_distance": "Lager en fas på hjørnet mellom to linjer, like langt tilbake langs begge. Bruk det til den vanlige 45°-fasen på en kontur.", + "help.sketch.chamfer.two_distance": "Lager en fas på hjørnet mellom to linjer, bestemt av en egen avstand langs hver linje. Bruk det til faser som ikke er 45°, oppgitt som to lengder.", + "help.sketch.circle.center": "Tegner en sirkel fra senteret og diameteren. Bruk det til hull, skruefester og andre runde former med kjent senter.", + "help.sketch.circle.three_point": "Tegner en sirkel gjennom tre punkter. Bruk det når sirkelen må gå gjennom gitte punkter, for eksempel på eksisterende geometri.", + "help.sketch.circle.three_tangent": "Tegner en sirkel som berører tre linjer, sirkler eller buer. Bruk det til å passe en sirkel inn i rommet mellom tre kurver, for eksempel den innskrevne sirkelen i en trekant.", + "help.sketch.circle.two_point": "Tegner en sirkel fra de to endene av diameteren. Bruk det når kantpunktene på sirkelen er kjent i stedet for senteret.", + "help.sketch.circle.two_tangent": "Tegner en sirkel i en gitt størrelse som berører to linjer, sirkler eller buer. Bruk det til en avrunding med fast radius mellom to kurver.", + "help.sketch.conic": "Tegner en kjeglesnittkurve mellom to endepunkter, formet av et toppunkt og en rho-verdi. Bruk det til jevne overganger der du vil justere fyldigheten, fra flat til nesten et skarpt hjørne.", + "help.sketch.constrainer": "Bruker bindingen som en eller to valgte kurver nesten har fra før, som vannrett, parallell, tangent eller lik. Bruk det til å rydde opp i en håndtegnet skisse én relasjon om gangen.", + "help.sketch.constraint.coincident": "Får et punkt til å ligge på et annet punkt, en kurve eller en skisseakse. Bruk det til å lukke mellomrom i en profil eller til å legge et punkt på en linje.", + "help.sketch.constraint.collinear": "Får to linjer til å ligge på én rett linje. Bruk det til å rette opp avbrutte kanter, for eksempel begge sidene av en utsparing.", + "help.sketch.constraint.concentric": "Gir to sirkler eller buer samme senter. Bruk det til ringer, sylindriske forsenkninger og aksler med skuldre.", + "help.sketch.constraint.curvature": "Får to kurver som møtes i et endepunkt til å fortsette med samme krumning (G2). Bruk det der en tangentforbindelse ikke er jevn nok, for eksempel på synlige flater.", + "help.sketch.constraint.equal": "Gjør linjer like lange, eller sirkler og buer like store i radius. Bruk det slik at ett mål styrer flere tilsvarende former.", + "help.sketch.constraint.fix": "Fikserer punkter eller kurver der de er, eller frigjør dem igjen. Bruk det til geometri som ikke må flytte seg når du drar i eller løser skissen.", + "help.sketch.constraint.horizontal_vertical": "Gjør en linje, eller linjen mellom to punkter, vannrett eller loddrett, avhengig av hva som er nærmest. Bruk det til å rette opp en kontur mot skisseaksene.", + "help.sketch.constraint.midpoint": "Legger et punkt midt på en linje, eller får midtpunktene til to linjer til å møtes. Bruk det til å sentrere geometri på en kant.", + "help.sketch.constraint.parallel": "Gjør to linjer parallelle. Bruk det til sider som må forbli parallelle når andre mål endres.", + "help.sketch.constraint.perpendicular": "Får to linjer til å møtes i rett vinkel. Bruk det til rette hjørner mellom linjer som ikke ligger på skisseaksene.", + "help.sketch.constraint.polygon": "Gjør en lukket kjede av linjer til en regulær mangekant: like sider, med hvert hjørne på én sirkel. Bruk det til å gjøre en håndtegnet mangekant regulær og holde den slik.", + "help.sketch.constraint.symmetry": "Får to punkter eller kurver til å speile hverandre om en linje eller et punkt. Bruk det til å holde en profil symmetrisk når du endrer den ene siden.", + "help.sketch.constraint.tangent": "Får to kurver til å berøre hverandre tangentielt, for eksempel en linje og en bue. Bruk det til jevne forbindelser uten knekk.", + "help.sketch.construction": "Bytter kurver mellom vanlig geometri og konstruksjonsgeometri, som profiler utelater. Bruk konstruksjonskurver som hjelp til utlegg, referanser og mål.", "help.sketch.construction_line": "Opprett en uendelig hjelpelinje gjennom to punkter. Bruk den til å legge opp en skisse uten å dele opp profilene.", "help.sketch.construction_line.angle": "Opprett en uendelig hjelpelinje i en vinkel fra skissens X-akse. Velg punktet og skriv inn vinkelen, eller velg retningen.", "help.sketch.construction_line.bisector": "Velg to rette skisselinjer som krysser hverandre, og velg deretter en av vinkelhalveringslinjene. Resultatet følger kildelinjene sine.", @@ -1412,11 +1564,71 @@ "help.sketch.construction_line.tangent_external": "Velg en sirkel eller sirkelbue, deretter et eksternt punkt og en tangentløsning. Forhåndsvisningene viser bare berøringspunkter på den tegnede buen.", "help.sketch.construction_line.tangent_two": "Velg to sirkler eller sirkelbuer, og velg deretter en felles tangent i forhåndsvisningen. Berøringspunktene må ligge på de tegnede kurvene.", "help.sketch.construction_line.vertical": "Opprett en uendelig loddrett hjelpelinje gjennom et punkt. Den loddrette bindingen følger med ved senere endringer.", + "help.sketch.copy": "Kopierer de valgte skissekurvene og punktene, sammen med bindingene og målene mellom dem. Lim dem inn i denne skissen eller en annen.", + "help.sketch.create": "Starter en ny 2D-skisse på et plan eller på en plan flate av modellen. Hver ekstrudering, omdreining og sweep begynner med en skisse.", + "help.sketch.cut": "Kopierer den valgte skissegeometrien og sletter den fra skissen, i ett angretrinn. Lim den inn et annet sted i denne skissen eller i en annen.", + "help.sketch.dimension": "Legger til et styrende mål: en lengde, avstand, vinkel, radius eller diameter, avhengig av hva du velger. Skriv inn en verdi eller et parameternavn, så følger skissen det.", + "help.sketch.edit": "Åpner en eksisterende skisse for redigering. Funksjonene som er bygget på den, oppdateres når du fullfører skissen.", + "help.sketch.ellipse": "Tegner en ellipse fra senteret, enden av storaksen og et punkt på den korte siden. Bruk det til ovale åpninger, kammer og andre avlange runde former.", "help.sketch.equation_curve": "Tegner en kurve ut fra formler: x(t) og y(t), eller y = f(x), over et intervall. Formlene kan bruke parametere, og kurven følger dem: bruk den til en sinusbølge, en spiral eller evolventen til en tannhjulstann.", + "help.sketch.extend": "Forlenger en linje eller bue til nærmeste kurve i veien, eller lar to kurver vokse til de møtes. Bruk det til å lukke mellomrom i en profil etter trimming eller redigering.", + "help.sketch.fillet": "Runder av hjørnet der to linjer eller buer møtes, med en bue med gitt radius. Flere hjørner som avrundes sammen, deler én radius.", + "help.sketch.finish": "Forlater skissen og går tilbake til modellen. De lukkede profilene er da klare for Ekstrudering, Omdreining og andre funksjoner.", + "help.sketch.finish_auto_constrain": "Kjører Automatiske bindinger på skissen og fullfører den deretter. Bruk det til å la en skisse være fullt bestemt i ett trinn.", + "help.sketch.fit_curves_to_section": "Skjærer en nettkropp eller volumkropp med skisseplanet og tilpasser linjer og buer til snittet. Bruk det til å gjenoppbygge en skannet eller importert del som en redigerbar skisse.", + "help.sketch.include_3d": "Henter modellkanter, hjørner og kurver fra andre skisser inn i denne skissen som koblede referanser, og flater ut de som ligger utenfor planet på det. Bruk det til å bygge på geometri som ikke ligger i skisseplanet.", + "help.sketch.infinite": "Bytter linjer mellom vanlige linjer og uendelige hjelpelinjer som går over hele visningen. Bruk dem som utleggsreferanser som aldri ender for kort.", + "help.sketch.insert_dxf": "Leser kurvene fra en DXF-tegning inn i en skisse, skalert, dreid og plassert slik du angir. Bruk det til å bygge på en 2D-tegning fra et annet program, for eksempel en kontur for laserskjæring.", + "help.sketch.insert_svg": "Leser banene fra en SVG-tegning inn i en skisse, skalert, dreid og plassert slik du angir. Bruk det til å gjøre en logo eller vektorkontur om til kurver du kan ekstrudere.", + "help.sketch.inspect": "Åpner en rapport om den åpne skissen: hvor mange frihetsgrader som gjenstår og om den er fullstendig bundet. Bruk det til å finne ut hva som fortsatt beveger seg før du fullfører skissen.", + "help.sketch.intersect": "Legger til kurvene eller punktene der kropper, flater, kanter eller andre skisser krysser skisseplanet. De forblir koblet og følger modellen når den endres.", + "help.sketch.intersection_curve": "Oppretter 3D-kurver der to kropper eller flater møtes, koblet til dem. Bruk dem som baner eller føringskurver som følger modellen.", + "help.sketch.isoparametric_curve": "Oppretter en 3D-kurve langs U- eller V-retningen til en flate gjennom et punkt du velger, koblet til flaten. Bruk den som en bane eller delelinje som følger flaten.", + "help.sketch.line": "Tegner sammenhengende linjer punkt for punkt, som en åpen kjede eller en lukket kontur. Det er det grunnleggende verktøyet for de fleste profiler.", + "help.sketch.line.midpoint": "Tegner en linje fra midtpunktet og ut til den ene enden, med den andre enden speilet. Bruk det til linjer sentrert på et punkt, for eksempel en symmetrisk kant.", + "help.sketch.line3d": "Tegner sammenhengende rette linjer mellom punkter i rommet, med 3D-skisse slått på. Bruk det til baner og rammer som forlater skisseplanet.", + "help.sketch.mirror": "Speiler skissekurver og punkter om en linje eller akse og legger til symmetribindinger. Bruk det til å tegne halvparten av en symmetrisk profil og fullføre den.", + "help.sketch.move": "Flytter eller roterer skissekurver og punkter, eller en kopi av dem. Bruk det til å plassere geometri på nytt og beholde formen og bindingene.", + "help.sketch.offset": "Lager en kopi av en kjede av kurver i en gitt avstand, på den ene siden eller begge. Bruk det til vegger, konturer og klaringer; avstanden forblir et redigerbart mål.", + "help.sketch.paste": "Limer inn skissegeometri du har kopiert eller klippet ut i den aktive skissen. Plasser den i et punkt eller forskyv den med en offset.", + "help.sketch.pattern.circular": "Kopierer skissegeometri rundt et senterpunkt, over en full sirkel eller en gitt vinkel. Bruk det til hullsirkler, eiker og tenner.", + "help.sketch.pattern.rectangular": "Kopierer skissegeometri i rader og kolonner med gitte avstander. Bruk det til rutenett av hull, spor eller ventilasjonsåpninger.", + "help.sketch.point": "Plasserer et skissepunkt. Bruk det som referanse for mål, bindinger, hullsentre eller senere funksjoner.", + "help.sketch.point3d": "Plasserer et punkt i rommet, med 3D-skisse slått på. Bruk det som referanse for 3D-kurver og baner.", + "help.sketch.polygon.circumscribed": "Tegner en regulær mangekant rundt en sirkel, dimensjonert fra senteret til midten av en side. Bruk det når bredden over flatene er kjent, som for en sekskantmutter.", + "help.sketch.polygon.edge": "Tegner en regulær mangekant fra de to endene av én side. Bruk det når lengden og posisjonen til en side er kjent.", + "help.sketch.polygon.inscribed": "Tegner en regulær mangekant inni en sirkel, dimensjonert fra senteret til et hjørne. Bruk det når målet over hjørnene er kjent.", + "help.sketch.project": "Henter modellkanter, flater, hjørner og kurver fra andre skisser inn i denne skissen, projisert på planet. De forblir koblet og følger modellen når den endres.", + "help.sketch.project_to_surface": "Projiserer kurver fra andre skisser langs normalen til denne skissen på en flate, som koblede 3D-kurver. Bruk det til å legge en logo, delelinje eller bane på en kurvet flate.", + "help.sketch.rectangle.center": "Tegner et rektangel fra senteret og ett hjørne. Bruk det til konturer sentrert på et punkt, for eksempel origo.", + "help.sketch.rectangle.rounded": "Tegner et rektangel med alle fire hjørnene avrundet til én radius. Bruk det til plater, lokk og åpninger med avrundede hjørner.", + "help.sketch.rectangle.three_point": "Tegner et rektangel fra én kant og et punkt på motsatt side. Bruk det til rektangler i vinkel mot skisseaksene.", + "help.sketch.rectangle.two_point": "Tegner et rektangel fra to motstående hjørner. Bruk det til konturene av plater, blokker og lommer.", + "help.sketch.scale": "Skalerer skissekurver og punkter med en faktor om et basepunkt. Bruk det til å endre størrelsen på en del av en skisse uten å tegne den på nytt.", + "help.sketch.slot.arc_center": "Tegner et buet spor med gitt bredde langs en bue bestemt av senteret, starten og slutten. Bruk det til justeringsspor rundt en dreietapp.", + "help.sketch.slot.arc_three_point": "Tegner et buet spor med gitt bredde langs en bue gjennom tre punkter. Bruk det når sporets bane må gå gjennom gitte punkter.", + "help.sketch.slot.center_point": "Tegner et rett spor med gitt bredde fra senteret til senteret av den ene endebuen. Bruk det til spor sentrert på et punkt.", + "help.sketch.slot.center_to_center": "Tegner et rett spor med gitt bredde mellom sentrene til de to endebuene. Bruk det til spor målsatt senter til senter, for eksempel justeringsspor for skruer.", + "help.sketch.slot.overall": "Tegner et rett spor med gitt bredde fra den ene ytterenden til den andre. Bruk det når sporets totale lengde er det som teller.", + "help.sketch.spline.control_point": "Tegner en kubisk spline formet av kontrollpunkter som den ikke går gjennom. Bruk det til jevne, flytende kurver som du former ved å flytte punktene.", + "help.sketch.spline.control_point_5": "Tegner en spline av grad 5 formet av kontrollpunkter. Bruk det der kurver må henge sammen med jevnere krumning, for eksempel på synlige designflater.", + "help.sketch.spline.fit_point": "Tegner en jevn spline som går gjennom hvert punkt du plasserer, åpen eller lukket. Bruk det til å tegne en form gjennom kjente punkter.", + "help.sketch.spline3d": "Tegner en jevn kurve i rommet gjennom punkter, med 3D-skisse slått på. Bruk det til sweep-baner og kabelføringer som forlater skisseplanet.", + "help.sketch.spun_profile": "Oppretter i skisseplanet omrisset en kropp tegner ut når den dreier om en akse. Bruk det til å få silhuetten av en dreiedel for en omdreining eller en tegning.", + "help.sketch.text": "Skriver tekst i skissen i en valgt skrift, høyde og vinkel; ordene kan følge parametere. Ekstruder eller preg den for etiketter og skrift.", + "help.sketch.transform": "Skalerer, dreier og flytter skissegeometri om et senterpunkt i ett trinn. Bruk det til å plassere og endre størrelsen på et utvalg samtidig.", + "help.sketch.trim": "Fjerner delen av en kurve mellom de nærmeste kryssingene rundt stedet du klikker. Bruk det til å rydde overlappende linjer til en lukket profil.", "help.solid.align": "Flytter de valgte kroppene slik at et punkt, en flate eller et plan på dem møter et punkt, en flate eller et plan et annet sted. Bruk det til å plassere en del mot en annen uten å måle forskyvningen.", + "help.solid.boundary_fill": "Lager volumkropper av områdene som omsluttes av overlappende verktøykropper, og fyller bare områdene du velger. Bruk det til å bygge en form av rommet flere kropper avgrenser sammen.", + "help.solid.bounding_solid": "Oppretter en boks som omslutter de valgte kroppene, med en valgfri margin. Bruk det til å se den samlede størrelsen på en del, eller som råemnet den freses ut av.", + "help.solid.box": "Oppretter en boks fra lengde, bredde og høyde, fra et hjørnepunkt eller sentrert på et punkt. Bruk det til en rask blokk, en plassholder eller en kropp å skjære funksjoner inn i.", "help.solid.chamfer": "Lager en plan fas langs de valgte kantene med avstanden du angir. Bruk verktøyet til å fjerne skarpe kanter eller gjøre det lettere å føre inn en pinne eller skrue.", + "help.solid.coil": "Oppretter en spiralformet kropp, for eksempel en fjær, fra en diameter, vindinger, høyde eller stigning og et rundt eller kvadratisk tverrsnitt. Bruk det til fjærer, spoler og spiralformede rygger.", "help.solid.combine": "Slår sammen kropper, trekker dem fra en målkropp eller beholder bare volumet de har felles, og kan beholde verktøykroppene. Bruk det til å slå sammen deler, til å skjære en form med en annen eller til å finne hvor to kropper overlapper.", + "help.solid.cone": "Oppretter en kjegle fra en grunnsirkel og en høyde, eller en avkortet kjegle når toppen også har en radius. Bruk det til tilspissinger, traktere, koniske forsenkninger og spisser.", + "help.solid.cylinder": "Oppretter en sylinder fra en radius eller diameter og en høyde, stående på et punkt langs en akse. Bruk det til aksler, stifter, skruefester og runde emner.", "help.solid.draft": "Tilter de valgte flatene med vinkelen du angir, målt fra et nøytralt plan. Bruk det på støpte eller formsprøytede deler slik at de glir ut av formen.", + "help.solid.emboss": "Hever skisseprofiler, for eksempel tekst eller en logo, ut fra en flate, eller senker dem ned i den. Bruk det til skrift, etiketter og merking på plane eller runde flater.", "help.solid.extrude": "Trekker en skisseprofil ut i 3D over en angitt avstand og legger til eller fjerner materiale. Bruk verktøyet til å gjøre et flatt omriss til en blokk, en knast eller en lomme.", "help.solid.fillet": "Runder av de valgte kantene med en jevn, sirkulær overgang med radiusen du angir. Bruk verktøyet til å avrunde skarpe kanter av hensyn til styrke, komfort eller utseende.", "help.solid.hole": "Lager et rundt hull med diameteren du angir, ved et valgt punkt på en flate. Bruk verktøyet til klaringshull for skruer, hull for pinner og andre enkle, runde åpninger.", @@ -1425,20 +1637,60 @@ "help.solid.move": "Flytter eller roterer de valgte kroppene, eller en kopi av dem, med avstanden og vinkelen du angir. Bruk det til å flytte en kropp eller lage en til på et annet sted.", "help.solid.offset_face": "Skyver de valgte flatene inn eller ut med avstanden du angir, og flatene rundt følger med. Bruk det til å gjøre en vegg tykkere, en lomme dypere eller en forhøyning høyere uten å redigere skissen.", "help.solid.pattern.circular": "Gjentar de valgte objektene rundt en akse, med antallet og totalvinkelen du angir. Bruk det til boltesirkler, eiker, finner og tenner.", + "help.solid.pattern.path": "Kopierer funksjoner eller kropper langs en skissebane, etter antall og avstand. Bruk det til hull langs en kurvet kant eller deler langs en skinne.", "help.solid.pattern.rectangular": "Gjentar de valgte objektene i én eller to retninger med antallet og avstanden du angir. Bruk det til rader og rutenett av hull, ribber, pinner eller føtter.", + "help.solid.pipe": "Oppretter et rundt rør langs en bane av skissekurver, massivt eller hult med en veggtykkelse. Bruk det til rør, ledninger, kabler og skinner.", "help.solid.press_pull": "Legger til eller fjerner materiale når du drar i en flate, eller avrunder kanter når du drar i dem. Bruk det til raske endringer rett på modellen.", "help.solid.remove": "Tar de valgte kroppene ut av designet fra dette punktet i tidslinjen og utover; tidligere trinn beholder dem. Bruk det til å fjerne en hjelpekropp, for eksempel et skjæreverktøy du ikke trenger lenger.", "help.solid.replace_face": "Flytter de valgte plane flatene til et plan eller en parallell flate, og kroppen vokser eller krymper for å møte det. Bruk det til å få en flate på linje med en annen del eller et konstruksjonsplan.", "help.solid.revolve": "Dreier en skisseprofil rundt en akse for å lage en rotasjonssymmetrisk volumkropp. Bruk verktøyet til aksler, knotter, skåler og andre deler som er runde rundt en senterlinje.", + "help.solid.rib": "Legger til en tynn vegg langs en åpen skissekurve, fylt ut til de neste flatene på kroppen. Bruk det til å avstive vegger og hjørner, for eksempel i støpte eller utskrevne deler.", "help.solid.scale": "Gjør de valgte kroppene større eller mindre med en faktor, lik i alle retninger eller én per akse. Bruk det til å endre størrelsen på en importert form eller til å ta hensyn til krymping.", "help.solid.shell": "Huler ut en volumkropp og lager vegger med tykkelsen du angir. Flatene du velger, fjernes for å lage åpninger. Bruk verktøyet til bokser, deksler og andre tynnveggede deler.", + "help.solid.sphere": "Oppretter en kule fra en radius eller diameter rundt et senterpunkt. Bruk det til kuler, knotter og avrundede ender.", "help.solid.split_body": "Deler en kropp i separate kropper med et plan, flaten til en annen kropp eller en skisselinje. Bruk det til å lage deler som skal skrives ut, maskineres eller farges hver for seg.", "help.solid.split_face": "Deler de valgte flatene langs et plan, flaten til en annen kropp eller skisserte kurver, uten å dele kroppen. Bruk det til å gi en del av en flate sin egen slippvinkel, farge eller forskyvning.", "help.solid.sweep": "Drar en skisseprofil langs en bane for å lage en volumkropp. Bruk det til rør, skinner, håndtak og alle deler med konstant tverrsnitt som følger en kurve.", + "help.solid.thread": "Legger til en ISO-metrisk gjenge på en sylindrisk flate, enten som en kosmetisk gjenge eller skåret inn i kroppen. Bruk det til bolter, gjengede hull og utskrevne skrudeler.", + "help.solid.torus": "Oppretter en ring fra en storradius rundt aksen og en lilleradius for det runde tverrsnittet. Bruk det til O-ringer, håndtak og runde perler.", + "help.solid.web": "Legger til en tynn vegg langs hver åpne skissekurve, hver fylt ut til de neste flatene på kroppen. Bruk det til et rutenett av støttevegger inni hus og brett.", + "help.surface.blend": "Oppretter en flate som bygger bro mellom kantene på to kropper, tangent- eller krumningskontinuerlig mot begge. Bruk det til å forbinde to flater jevnt over et mellomrom.", + "help.surface.boundary": "Oppretter en flate som fyller en sløyfe av to til fire skissekurver, eller et nett av kurver. Bruk det til frie paneler definert av kantene sine.", + "help.surface.extend": "Forlenger en flate forbi kantene, med en avstand eller opp til et plan eller en flate. Bruk det slik at flater overlapper før du trimmer eller syr dem sammen.", + "help.surface.extrude": "Skyver skissekurver eller profiler en avstand ut til en flate, bare vegger, uten lokk. Bruk det til tynne vegger og til skjære- eller delingsverktøy.", + "help.surface.fair": "Jevner ut innsiden av frie flater mens kantene blir stående. Bruk det til å jevne ut bulker og bølgete refleksjoner i en flate.", + "help.surface.loft": "Oppretter en flate som går gjennom to eller flere profiler i rekkefølge, uten lokk. Bruk det til skall og overganger mellom ulike former.", + "help.surface.match": "Bygger kanten av en flate på nytt slik at den møter kanten til en annen kropp, med samsvarende tangent eller krumning. Bruk det til å lukke en skjøt jevnt mellom to flater.", + "help.surface.offset": "Lager en kopi av flater i en avstand langs normalene deres, som en ny flatekropp. Bruk det til hud, klaringer eller den andre siden av en vegg.", + "help.surface.patch": "Fyller et skisseområde eller en lukket sløyfe av kanter med en flate, plan eller jevnt krum. Bruk det til å lukke hull i en flatekropp.", + "help.surface.reverse_normal": "Snur flatene i en flatekropp, slik at utsiden vender den andre veien. Bruk det før fortykning eller sammensying når flatene peker innover.", + "help.surface.revolve": "Dreier skissekurver eller profiler om en akse til en flate, uten lokk. Bruk det til runde skall som lampeskjermer, boller og dyser.", + "help.surface.ruled": "Oppretter en stripe av rette linjer ut fra en kjede av kanter, langs flatens normal, tangent eller i en vinkel. Bruk det til slippvegger, utløp og delingsflater.", + "help.surface.stitch": "Syr flatekropper sammen langs de felles kantene, til en volumkropp når de lukker seg. Bruk det til å gjøre et sett flater om til én kropp.", + "help.surface.sweep": "Fører skissekurver eller profiler langs en bane til en flate, uten lokk. Bruk det til renner, lister og andre tynne former som følger en bane.", + "help.surface.thicken": "Gir en flatekropp en tykkelse og gjør den til en volumkropp. Bruk det til å lage utskrivbare eller maskinerbare deler av flater.", + "help.surface.trim": "Skjærer en flate med et plan, en annen flate eller skissekurver, og beholder siden du velger. Bruk det til å skjære flater til form før sammensying.", + "help.surface.unstitch": "Skiller flatene til en kropp ut i egne flatekropper; en volumkropp faller fra hverandre til flater. Bruk det til å bearbeide enkeltflater på en kropp.", + "help.surface.untrim": "Fjerner hull fra en flate, eller gjenoppretter ytterkanten til flatens naturlige grenser. Bruk det til å få tilbake hele flaten etter en trimming.", "help.third_party": "Tredjepartslisenser", "help.timeline.compute_all": "Bygger alle funksjoner i tidslinjen på nytt fra begynnelsen. Bruk det til å oppdatere modellen og se hvilke funksjoner som feiler.", "help.timeline.delete": "Sletter de valgte funksjonene fra tidslinjen; sletting av en skisse sletter også funksjonene som er bygget på den. Bruk det til å fjerne et trinn som ikke lenger er ønsket.", + "help.timeline.dependents": "Lister opp funksjonene som ville blitt slettet sammen med en funksjon, og de som ville feilet uten den. Bruk det før du sletter eller endrer en funksjon.", "help.timeline.play": "Spill av de gjenstående funksjonene én om gangen, med en halv sekunds pause etter hver gjenoppbygging. Kjør denne kommandoen igjen eller trykk Escape for å stoppe; Gå til slutten hopper umiddelbart.", + "help.view.apply": "Dreier visningen til en standardretning som front, topp eller iso, eller til en lagret navngitt visning. Bruk det til å se på modellen fra en fast vinkel.", + "help.view.delete": "Sletter en lagret navngitt visning. Modellen endres ikke.", + "help.view.fit": "Zoomer og panorerer slik at hele modellen, eller utvalget, fyller visningen. Bruk det når modellen har kommet ut av syne.", + "help.view.grid": "Viser eller skjuler rutenettet i visningsområdet. Bruk det som en størrelses- og orienteringshjelp under modellering.", + "help.view.ground_shadow": "Viser eller skjuler skyggen til modellen på bakken. Bruk det til å vurdere høyde og posisjon lettere.", + "help.view.home": "Dreier visningen tilbake til hjemretningen og tilpasser modellen. Bruk det til raskt å komme tilbake til en kjent visning.", + "help.view.list": "Lister opp de navngitte visningene som er lagret i designet. Bruk det til å finne en visning du lagret tidligere.", + "help.view.navigate": "Dreier, panorerer eller zoomer visningen i trinn, for eksempel en kvart omdreining til venstre. Bruk det til å flytte kameraet presist i stedet for å dra.", + "help.view.navigation_bar": "Viser eller skjuler navigasjonslinjen nederst i visningsområdet. Skjul den for mer plass, vis den for å nå visningsverktøyene med musen.", + "help.view.rename": "Gir en lagret navngitt visning et nytt navn. Selve visningen forblir den samme.", + "help.view.save": "Lagrer gjeldende kameraposisjon som en navngitt visning i designet. Bruk det til å komme tilbake til samme vinkel senere, for eksempel for skjermbilder.", + "help.view.spin": "Roterer visningen rundt modellen kontinuerlig til neste klikk eller tast. Bruk det til å presentere en modell fra alle sider.", + "help.view.tool": "Låser et kameraverktøy, rotering, panorering eller zoom, slik at det å dra med venstre museknapp flytter visningen til du slår det av. Bruk det på en styreflate eller uten midtre museknapp.", + "help.view.visual_style": "Bytter hvordan modellen tegnes: skyggelagt med eller uten kanter, trådmodell eller med skjulte kanter. Bruk trådmodell for å se inn i modellen, skyggelagt for et rent utseende.", "history.sketch_under_constrained": "Underbestemt ({count} frihetsgrader)", "history.sketch_under_constrained_one": "Underbestemt ({count} frihetsgrad)", "hole_presets.confirm_delete": "Bekreft sletting", @@ -1615,6 +1867,10 @@ "material.selection.mixed": "Ulike materialer", "measure.at": "Ved", "measure.item": "Element {number}", + "measure.quick_copied": "Målet er kopiert", + "measure.quick_copy": "Klikk for å kopiere målet", + "measure.quick_edges": "{count} kanter", + "measure.quick_faces": "{count} flater", "messages.ago_h": "for {n} t siden", "messages.ago_min": "for {n} min siden", "messages.ago_s": "for {n} s siden", @@ -1767,6 +2023,11 @@ "not a move": "ikke en flytting", "not a number": "ikke et tall", "open a sketch first": "åpne en skisse først", + "parameters.apply_live": "Bruk hver endring når du forlater feltet", + "parameters.apply_live_tip": "Av: endringene venter til Bruk eller OK, og så bygges designet på nytt én gang for alle. Avbryt eller Escape forkaster dem.", + "parameters.held": "{count} endringer venter på Bruk", + "parameters.held_hint": "Rediger uttrykk og enheter. Endringer venter på Bruk eller OK.", + "parameters.held_one": "1 endring venter på Bruk", "parts.automatic_name": "automatisk", "parts.dimension.across_flats": "Nøkkelvidde", "parts.dimension.bore": "Boring", @@ -1789,6 +2050,8 @@ "parts.family.set_screw": "Settskrue med innvendig sekskant, flat ende", "parts.family.socket_head_cap_screw": "Sylinderskrue med innvendig sekskant", "parts.family.washer": "Underlagsskive", + "parts.holes": "Hull (valgfritt)", + "parts.placement_hint": "velg ett eller flere hull for å sette inn en del i hvert av dem, eller OK plasserer den ved origo", "pattern.alignment_2": "Justering 2", "pattern.alignment_hint": "Antallet inkluderer originalen. Senter fordeler kopiene rundt originalen. Speil velger hvilken side som får en ekstra kopi når antallet er et partall.", "pattern.direction_1": "Retning 1", @@ -1955,6 +2218,8 @@ "prefs.origin_plane_names": "Navn på origoplan", "prefs.origin_plane_names_note": "Navn som vises i modelltreet, dialoger og lister. {top} er XY, {front} er XZ og {right} er YZ, med Z opp. Kommandoer, skript og lagrede design beholder XY, XZ og YZ.", "prefs.overall_speed": "Samlet hastighet", + "prefs.parameter_list_rows": "Rader i parameterlisten", + "prefs.parameter_list_rows_tip": "Pil ned i et tomt verdifelt viser parameterne, favorittene først, opp til dette antallet. Færre holder listen lett i et stort design.", "prefs.persistent_ribbon_pressed": "Fremhev aktive kommandoer på båndet", "prefs.persistent_ribbon_pressed_help": "Fremhev kommandoer på båndet mens effektene deres er slått på. Slå av for å beholde det forrige nøytrale utseendet og den forrige kommandohjelpen.", "prefs.perspective_fov": "Synsfelt", @@ -1984,6 +2249,8 @@ "prefs.quality_fastest": "raskest", "prefs.quality_slowest": "langsomst", "prefs.quality_smoother": "jevnere", + "prefs.quick_measure": "Hurtigmåling i hjørnet av visningen", + "prefs.quick_measure_note": "Størrelsen på utvalget nederst til høyre i visningen: lengden til en kant, eller Ø og R for en rund kant, arealet til en flate, posisjonen til et punkt, avstanden og vinkelen mellom to elementer, summen for flere kanter eller flater. Et klikk kopierer den.", "prefs.render_previous_geometry_budget": "Bruk forrige geometribudsjett for rendringsforhåndsvisning", "prefs.render_previous_geometry_budget_help": "Gjenopprett den forrige faste geometrigrensen (512 MiB på 64-biters skrivebordsmaskiner, 128 MiB ellers) i stedet for budsjettet som er basert på enheten.", "prefs.reports_saved": "{n} lagret", @@ -2029,6 +2296,8 @@ "prefs.sketch_tab_always_note": "Skisse-fanen blir stående på båndet, rett etter Modell, også når ingen skisse er åpen. Tegneverktøyene starter da en skisse på det valgte planet eller den valgte flaten, eller på planet du velger deretter, med verktøyet klart i skissen.", "prefs.sketch_tracking_guides": "Hjelpelinjer for senter og midtpunkt", "prefs.sketch_tracking_guides_note": "Hold over en sirkels senter eller midtpunktet på en rett kant, og flytt deretter vannrett eller loddrett for å vise en midlertidig prikket hjelpelinje.", + "prefs.sketch_tracking_points": "Hjelpelinjer fra endepunkter og hjørner under tegning", + "prefs.sketch_tracking_points_note": "Mens et tegneverktøy venter på et punkt, viser det å holde pekeren over enden av en linje eller bue, hjørnet av et rektangel eller et fritt punkt den samme midlertidige stiplede hjelpelinjen som for et senter eller midtpunkt. Av: bare sentre og midtpunkter.", "prefs.sketching": "Skissetegning", "prefs.snap_drawing_note": "Mens du tegner eller drar et skissepunkt.", "prefs.source": "Kilde", @@ -2056,6 +2325,16 @@ "prefs.theme_follow_off": "Av", "prefs.theme_follow_stop": "Slutt å følge", "prefs.theme_import_hint": "Klikk for å velge en temafil, slipp en her, eller trykk Ctrl+V (Cmd+V på Mac) med et kopiert tema", + "prefs.theme_option_opacity": "Opasitet", + "prefs.theme_option_opacity_help": "Juster opasiteten til flytende paneler. En lesbarhetsgrense beskytter tekst over visningsfarger.", + "prefs.theme_option_tab_colours": "Fanefarger", + "prefs.theme_option_tab_colours_help": "Bruk dette temaets panel- og visningsfarger for hver båndfane.", + "prefs.theme_option_tab_fade_ms": "Faneovergang", + "prefs.theme_option_tab_fade_ms_help": "Toner panel- og visningsfarger når du bytter fane. Null bytter umiddelbart.", + "prefs.theme_options_badge": "Alternativer", + "prefs.theme_options_more": "Flere · {count}", + "prefs.theme_options_reset": "Tilbakestill", + "prefs.theme_options_unavailable": "Ikke tilgjengelig på denne enheten", "prefs.theme_paste": "Lim inn tema", "prefs.theme_paste_none": "Utklippstavlen inneholder ikke noe SolidCraft-tema", "prefs.timeline_half_hour": "−30 min", @@ -2077,6 +2356,8 @@ "prefs.under_cursor": "Under pekeren", "prefs.units_precision": "Enheter og presisjon", "prefs.updates": "Oppdateringer", + "prefs.value_boxes_in_view": "Vis verdier ved siden av håndtakene i visningen", + "prefs.value_boxes_in_view_tip": "Verdien til en kommando (Avstand, Radius …) i en boks ved siden av håndtaket, over modellen. Av: du skriver den inn i kommandovinduet, som tar over tastaturet etter et valg, og ingenting dekker modellen.", "prefs.view": "Visningsatferd", "prefs.view_confirm_corner": "OK og Avbryt i hjørnet av visningen", "prefs.view_confirm_corner_note": "Mens en dialog eller en skisse er åpen, gjør et hakemerke og et kryss øverst til høyre i visningen det samme som OK og Avbryt i dialogen, eller fullfører og avbryter skissen. Visningsnavigatoren flyttes ned under dem.", @@ -2086,6 +2367,12 @@ "prefs.viewport_background": "Bakgrunn", "prefs.viewport_background_colour": "Farge", "prefs.viewport_background_theme": "Temaets gradient", + "prefs.viewport_section_status": "Vis snittstatus i visningsområdet", + "prefs.viewport_section_status_help": "Vis snittinnstillingen i visningsområdet. Escape og den aktive båndknappen slår den av. Slå av for å beholde de tidligere snittkontrollene.", + "prefs.viewport_status_details": "Live detaljer i visningsområdet", + "prefs.viewport_status_details_help": "Rediger aktive analyse- og snittinnstillinger i vanlige detaljkort. Slå av for å gjenopprette skrivebeskyttede tegnforklaringer for analyse og Snitt-dialogen.", + "prefs.viewport_status_summary": "Slå sammen statuselementer i visningsområdet", + "prefs.viewport_status_summary_help": "Slå sammen tre eller flere statuselementer i visningsområdet til ett oppsummeringsmerke. Slå av for å beholde hvert element i hele raden.", "prefs.wheel": "Musehjul", "prefs.wheel_trackpad": "Musehjul og styreflate", "prefs.workspace": "Arbeidsområde", @@ -2964,7 +3251,9 @@ "render.output_ready": "Klar til rendring", "render.output_run_check": "Kjør sjekk", "render.output_size_limit": "{method}: opptil {dimension} px per side og {pixels} piksler. Største størrelse som passer: {width} × {height}.", + "render.output_size_method_profile": "For å rendre {width} × {height}, velg {method} med CPU-ressursprofilen {profile}, eller en mindre størrelse.", "render.output_size_ok": "Størrelse OK · {width} × {height} px", + "render.output_size_profile": "For å rendre {width} × {height}, velg CPU-ressursprofilen {profile}, eller en mindre størrelse.", "render.output_swap_orientation": "Bytt bredde og høyde", "render.output_tools_inspector": "Rediger størrelse og format i utdatainspektøren.", "render.output_view_image": "Vis bilde", @@ -3177,6 +3466,7 @@ "render.size_custom": "Egendefinert…", "render.size_full_hd": "Full HD", "render.size_hd": "HD", + "render.size_needs_profile": "{size} · trenger {profile}", "render.size_presets": "Forhåndsinnstillinger for størrelse", "render.size_presets_pending": "Kommer med utdataeditoren", "render.size_preview": "Forhåndsvisning", @@ -3464,6 +3754,24 @@ "sheet.flange_type_contour": "Kontur", "sheet.flange_type_edge": "Kant", "sheet.no_body": "det finnes ingen platemetallkropp: start en med Flens > Basis, eller gjør en plate om til platemetall med Konverter til platemetall (velg den store, flate siden)", + "sheet.personal_rules.confirm_delete": "Bekreft sletting", + "sheet.personal_rules.delete": "Slett", + "sheet.personal_rules.delete_prompt": "Slette denne personlige regelen? Regler som allerede ligger i design, endres ikke.", + "sheet.personal_rules.full": "Det personlige biblioteket inneholder allerede 64 regler.", + "sheet.personal_rules.invalid_name": "Skriv inn et kort navn uten linjeskift eller kontrolltegn.", + "sheet.personal_rules.invalid_values": "Fullfør verdiene i regelen: hver må kunne beregnes i dette designet, med positiv tykkelse og en K-faktor fra 0 til 1.", + "sheet.personal_rules.missing": "Den valgte personlige regelen er ikke lenger tilgjengelig.", + "sheet.personal_rules.name": "Regelnavn", + "sheet.personal_rules.name_exists": "Det finnes allerede en personlig regel med dette navnet. Bruk et annet navn.", + "sheet.personal_rules.name_hint": "Et unikt navn", + "sheet.personal_rules.overwrite": "Lagre endringer i valgt personlig regel", + "sheet.personal_rules.rename": "Gi nytt navn", + "sheet.personal_rules.rule": "Personlig regel", + "sheet.personal_rules.save": "Lagre som ny", + "sheet.personal_rules.scope": "Lagres i profilen din for alle design.", + "sheet.personal_rules.select": "Velg en regel", + "sheet.personal_rules.title": "Personlige regler", + "sheet.personal_rules.unsupported": "Denne regelen har verdier som denne versjonen ikke kan bruke.", "shortcut.json_error": "parametere må være JSON: {error}", "shortcut.no_command": "ingen kommando `{id}`; teksten før JSON må være en kommando-id", "shortcut.pin": "Fest øverst i S-boksen", @@ -3500,6 +3808,7 @@ "sketch_text.align_right": "Høyre", "sketch_text.bold": "Fet", "sketch_text.builtin_font": "{family} (innebygd)", + "sketch_text.formula": "Start med = for å bygge ordene fra parametere: ='W' + number(Width)", "sketch_text.italic": "Kursiv", "sketch_text.loading_fonts": "Ser etter installerte skrifter…", "sketch_text.on_path": "Teksten følger banen og plasseres langs kurven", @@ -3640,6 +3949,8 @@ "ui.3d_mouse_section": "3D-mus", "ui.3d_mouse_show_in_view": "Vis 3D-musen i 3D-visningen", "ui.3d_mouse_show_in_view_tip": "Et bilde av 3D-musen din i nedre venstre hjørne som beveger seg når du beveger den.", + "ui.3d_mouse_sketch_orbit": "Roter skisser med 3D-musen", + "ui.3d_mouse_sketch_orbit_tip": "3D-musen roterer også visningen mens en skisse er åpen og sett rett forfra, slik musen kan. Av: den panorerer og zoomer skissen til du heller visningen.", "ui.3d_mouse_skip": "Enheten min kan ikke gjøre dette", "ui.3d_mouse_skip_tip": "Hopper over denne bevegelsen i begge retninger, og kalibreringen utelater den.", "ui.3d_mouse_speed": "3D-musehastighet", @@ -3708,6 +4019,7 @@ "ui.agent_chat_auth_terminal": "Kjør dette i en terminal, og trykk deretter Koble til igjen:", "ui.agent_chat_auth_title": "Logg inn på agenten", "ui.agent_chat_cancel": "Avbryt", + "ui.agent_chat_cannot_start": "Installert, men starter ikke: {error}", "ui.agent_chat_changes": "Endringer: {n}, ett angretrinn", "ui.agent_chat_changes_n": "{n} endringer", "ui.agent_chat_changes_of": "{n}/{limit} endringer", @@ -3800,9 +4112,12 @@ "ui.agent_chat_queued_stopped": "I kø (stoppet, ikke sendt)", "ui.agent_chat_refresh": "Oppdater", "ui.agent_chat_refused": "Ikke tillatt for agenten: {reason}", + "ui.agent_chat_reinstall": "{agent} er installert ({program}), men startet ikke da SolidCraft sjekket den: {error}\nInstallasjonen kan være ufullstendig, eller laget for en annen type datamaskin. Installer den på nytt med `{install}`, kontroller at den skriver ut versjonen sin i en terminal, og trykk deretter Oppdater.", "ui.agent_chat_remove": "Fjern fra køen", "ui.agent_chat_remove_file": "Fjern vedlegget fra denne meldingen", "ui.agent_chat_remove_imported": "Fjern vedlegget fra denne meldingen. Delen blir i designet (Angre fjerner den).", + "ui.agent_chat_reply_cut_off": "Svaret nådde lengdegrensen før det var ferdig, så det kan være ufullstendig. Be agenten fortsette, eller om et kortere svar.", + "ui.agent_chat_reply_declined": "Modellen avslo å svare på denne meldingen.", "ui.agent_chat_retry": "Prøv igjen", "ui.agent_chat_right": "Høyre", "ui.agent_chat_save": "Lagre", @@ -3861,12 +4176,17 @@ "ui.ai_copy_suffix": "kopi", "ui.ai_custom_url": "Egendefinert URL", "ui.ai_default": "Standard", + "ui.ai_edit": "Rediger", + "ui.ai_edit_agent_title": "Rediger agent", + "ui.ai_edit_gone": "Denne agenten ble fjernet, så det er ingenting å lagre.", + "ui.ai_edit_tip": "Endre navn, kommando eller endepunkt for denne agenten. En lagret nøkkel forblir skjult.", "ui.ai_http_busy": "endepunktet er opptatt eller over grensen; prøv igjen senere", "ui.ai_http_key": "endepunktet avviste nøkkelen: sjekk den i Innstillinger > AI", "ui.ai_http_not_found": "endepunktet eller modellen finnes ikke; kontroller base-URL og modellnavn", "ui.ai_http_too_large": "samtalen er for stor for dette endepunktet; start en ny chat", "ui.ai_key": "API-nøkkel", "ui.ai_key_hint": "valgfritt; lagres i systemets nøkkelring", + "ui.ai_key_keep_hint": "la stå tom for å beholde den lagrede nøkkelen", "ui.ai_key_needs_https": "En nøkkel sendes bare over HTTPS, eller til denne datamaskinen.", "ui.ai_key_note": "Lagres i systemets nøkkelring, aldri i SolidCrafts innstillinger. La den stå tom for en lokal server.", "ui.ai_keychain_error": "Systemets nøkkelring avviste forespørselen: {error}", @@ -3898,6 +4218,7 @@ "ui.ai_request_timeout": "Tidsavbrudd for forespørsel", "ui.ai_request_timeout_hint": "Hvor lang tid én modellforespørsel til dette endepunktet kan ta, fra 30 s til en time. Øk verdien for en treg lokal modell.", "ui.ai_reset": "Tilbakestill", + "ui.ai_save": "Lagre", "ui.ai_settings": "AI-innstillinger…", "ui.ai_show_features": "Vis AI-funksjoner", "ui.ai_show_features_note": "Agentchat er et panel der en kodeagent du allerede bruker, eller et modellendepunkt du velger, arbeider på det åpne designet. Når dette er av, vises ingen AI-knapp, snarvei, menypunkt eller panel, og ingen agenter søkes etter eller startes.", @@ -4071,6 +4392,40 @@ "ui.design_properties.project": "Prosjekt", "ui.design_properties.revision": "Revisjon", "ui.design_saved": "Lagret i {path}", + "ui.details_access_caption": "Flatene viser om et verktøy kan nå dem fra den valgte retningen.", + "ui.details_angle": "Vinkel", + "ui.details_continuity_caption": "Rediger den valgte skjøten. Andre skjøter beholder sine egne innstillinger.", + "ui.details_continuity_stale": "Tidligere geometri: kjør analysen på nytt for å redigere eller innramme denne skjøten.", + "ui.details_copy_list": "Kopier listen", + "ui.details_curvature": "Krumningstoleranse", + "ui.details_curvature_caption": "Flatene fargelegges etter lokal krumningsradius.", + "ui.details_direction": "Verktøyretning", + "ui.details_draft_caption": "Flatene fargelegges etter vinkelen til trekkretningen.", + "ui.details_edit_seam": "Rediger skjøt", + "ui.details_fixture": "Komponenteksempel — bare eksempeldata", + "ui.details_fixture_caption": "Komponenteksempel med illustrerende verdier; dette er ikke et modellresultat.", + "ui.details_fixture_problem": "Eksempelfunn med et problem", + "ui.details_fixture_readout": "Eksempel på målt verdi", + "ui.details_flip": "Snu", + "ui.details_gap": "Mellomrom", + "ui.details_hatch": "Skraver snittflatene", + "ui.details_jump": "Krumningssprang", + "ui.details_key": "Tegnforklaring", + "ui.details_offset": "Offset", + "ui.details_plane": "Plan", + "ui.details_pull": "Trekkretning", + "ui.details_radius": "Radius", + "ui.details_readout": "Avlesning", + "ui.details_samples": "Prøver", + "ui.details_seam": "{body} · Kant {edge}", + "ui.details_settings": "Innstillinger", + "ui.details_sharp_gap": "Skarp eller mellomrom", + "ui.details_show_all": "Vis alle {count}", + "ui.details_smooth": "Jevn", + "ui.details_stripes": "Striper", + "ui.details_target": "Innram geometrien", + "ui.details_worst_seams": "Dårligste skjøter", + "ui.details_zebra_caption": "Reflekterte striper viser hvor jevnt flatene går sammen.", "ui.diameter": "Diameter", "ui.dimension": "Mål", "ui.direct_interaction": "Direkte interaksjon", @@ -4131,6 +4486,7 @@ "ui.equation_y_of_x": "y(x)", "ui.equivalent_radius": "Ekvivalent radius", "ui.expand": "Utvid", + "ui.explode_needs_two_components": "Eksplodert visning trekker komponenter fra hverandre, så designet må ha to eller flere av dem (Ny komponent, Sett inn design eller Opprett komponenter fra kropper)", "ui.exportDxf": "Eksporter DXF…", "ui.export_ellipsis_keymap": "Eksporter…", "ui.export_keymap_tip": "Taster, S-boks-favoritter og verktøylinjen, som en fil", @@ -4419,12 +4775,17 @@ "ui.plugins_skipped": "{count} mappe(r) kunne ikke leses.", "ui.plugins_version": "{version}", "ui.point": "Punkt", + "ui.point_cloud_add_filter": "Legg til filter…", "ui.point_cloud_auto_range": "Automatisk område", "ui.point_cloud_color_by": "Farge etter", "ui.point_cloud_file_colors": "Filfarger", + "ui.point_cloud_filter_by": "Filtrer etter", "ui.point_cloud_height_high": "Høyeste", "ui.point_cloud_height_low": "Laveste", + "ui.point_cloud_legend_filter": "Vises og skjules i tegnforklaringen ovenfor", "ui.point_cloud_no_file_colors": "Filen har ingen farger", + "ui.point_cloud_pause": "Pause", + "ui.point_cloud_play": "Spill av", "ui.point_cloud_single_color": "Én farge", "ui.point_cloud_size": "Punktstørrelse", "ui.point_cloud_units": "Filenheter", @@ -4453,6 +4814,7 @@ "ui.properties.inertia_y": "Iyx, Iyy, Iyz", "ui.properties.inertia_z": "Izx, Izy, Izz", "ui.properties.measuring": "Måler…", + "ui.properties.measuring_bodies": "Måler… {done} av {total} legemer", "ui.properties.measuring_failed": "Målingen stoppet før den var ferdig.", "ui.properties.principal_axis": "Hovedakse {number}", "ui.properties.principal_moments": "Hovedtreghetsmomenter", @@ -4520,6 +4882,7 @@ "ui.selectOther": "Velg annet…", "ui.selectTangentChain": "Velg tangentkjede", "ui.select_all": "Velg alle", + "ui.select_components_by_face": "Velg komponenter (en flate velger komponenten sin)", "ui.selection": "Utvalg", "ui.selection_modifiers": "Utvalgsmodifikatorer", "ui.selection_shift_add_only": "Shift+klikk legger til uten å fjerne", @@ -4561,6 +4924,11 @@ "ui.sketch_intersect_selection_filter": "Utvalgsfilter", "ui.sketch_modifiers": "Skissemodifikatorer", "ui.sketch_project_geometry": "Geometri", + "ui.sketch_report.all_constrained": "Alt er fullstendig bundet.", + "ui.sketch_report.dof": "Frihetsgrader", + "ui.sketch_report.loose": "Ikke fullstendig bundet", + "ui.sketch_report.status": "Status", + "ui.sketch_report.title": "Skisserapport", "ui.sketch_scale": "Skisseskala", "ui.sketches": "Skisser", "ui.slice": "Snittvisning", @@ -4670,7 +5038,14 @@ "ui.viewport_status_key": "Vis tegnforklaring for {name}", "ui.viewport_status_minimum_radius": "Minimumsradius", "ui.viewport_status_pairs": "{count} par", + "ui.viewport_status_section_hatch": "{setting} · skravur", + "ui.viewport_status_section_plane": "Gjeldende plan", + "ui.viewport_status_section_tilted": "skråstilt · {offset}", "ui.viewport_status_stripes": "{count} striper", + "ui.viewport_status_summary_clear": "Slå av alt", + "ui.viewport_status_summary_heading": "I denne visningen", + "ui.viewport_status_summary_label": "{name} og {count} elementer til", + "ui.viewport_status_summary_more": "+{count}", "ui.viewport_theme_follow": "Følg tema", "ui.visual_shaded_hidden_edges": "Skyggelagt med skjulte kanter", "ui.visual_shaded_with_edges": "Skyggelagt med kanter", diff --git a/assets/locales/nl.json b/assets/locales/nl.json index 40a5b54a2d..84ed835eb6 100644 --- a/assets/locales/nl.json +++ b/assets/locales/nl.json @@ -60,6 +60,7 @@ "BLEND SURFACE": "SAMENVOEGOPPERVLAK", "BOUNDARY SURFACE": "BEGRENZINGSOPPERVLAK", "BROWSER": "BROWSER", + "Back to the original order": "Terug naar de oorspronkelijke volgorde", "Ball": "Kogel", "Bar": "Staaf", "Base Center": "Basismiddelpunt", @@ -595,6 +596,8 @@ "Snap to face and edge centres": "Snappen op middelpunten van vlakken en randen", "SolidCraft cannot submit reports directly. Submit on GitHub; no token is needed in the app.": "SolidCraft kan rapporten niet rechtstreeks indienen. Dien ze in op GitHub; in de app is geen token nodig.", "SolidCraft closed before these designs were saved. Their last autosave can be opened.": "SolidCraft is gesloten voordat deze ontwerpen zijn opgeslagen. Hun laatste automatische opslag kan worden geopend.", + "Sort A to Z": "Sorteren van A tot Z", + "Sort Z to A": "Sorteren van Z tot A", "Spacing 2 must be a non-zero number": "Tussenruimte 2 moet een getal ongelijk aan nul zijn", "Spacing must be a non-zero number": "De tussenruimte moet een getal ongelijk aan nul zijn", "Specialise": "Specialiseren", @@ -819,6 +822,7 @@ "cmd.appearance_pick_up": "Uiterlijk oppakken", "cmd.appearance_replace": "Uiterlijk vervangen", "cmd.assembly_bom": "Stuklijst", + "cmd.assembly_mass_properties": "Massa-eigenschappen", "cmd.body.print_face": "Printvlak", "cmd.body_rename": "Lichaam hernoemen", "cmd.browser_collapse_all": "Alles samenvouwen", @@ -1244,6 +1248,7 @@ "cmd.view.render_browser": "Renderbrowser", "cmd.view.render_dock": "Renderdock verplaatsen", "cmd.view.render_focus": "Renderfocusweergave", + "cmd.view.status_details": "Statusdetails in de viewport", "cmd.view.status_items": "Statusitems in de viewport", "cmd.view_ambient_occlusion": "Ambient occlusion", "cmd.view_angle_readout": "Weergavehoeken", @@ -1387,21 +1392,168 @@ "from the rule": "uit de regel", "help.apache": "Apache-licentie 2.0", "help.appearance.assign": "Geeft de geselecteerde lichamen, vlakken of componenten een kleur of materiaaluiterlijk, zonder hun gewicht te wijzigen. Gebruik dit om onderdelen van elkaar te onderscheiden of een afwerking in voorbeeld te bekijken.", + "help.assembly.bom": "Toont de onderdelen van het ontwerp of van een component met hun aantallen, per onderdeelnummer, en kan de lijst opslaan als CSV of JSON. Gebruik dit als onderdelenlijst voor bestellen en monteren.", "help.attributions": "Naamsvermeldingen (ATTRIBUTION.md)", "help.build": "Versie {version} · commit {commit} · gebouwd {date}", + "help.canvas.decal": "Plaatst een afbeelding op een vlak van het model, als een sticker. Gebruik dit om een label, logo of opgedrukt patroon op een onderdeel te tonen.", + "help.canvas.edit": "Wijzigt een canvas: de positie, grootte, hoek, dekking, spiegeling, zichtbaarheid of naam. Gebruik dit om een referentieafbeelding uit te lijnen met de schets die je erover overtrekt.", + "help.canvas.insert": "Plaatst een PNG- of JPEG-afbeelding als doorzichtige referentie op een vlak. Gebruik dit om een foto of tekening in een schets over te trekken, met de afbeelding op een bekende maat geschaald.", + "help.component.break_link": "Maakt een gekoppeld component los van zijn ontwerpbestand en laat de lichamen ongewijzigd. Gebruik dit wanneer het onderdeel niet langer wijzigingen in het oorspronkelijke bestand hoeft te volgen.", + "help.component.capture_position": "Legt de posities vast van onderdelen die je met de hand hebt verplaatst, zodat die posities hun plaats worden. Gebruik dit nadat je onderdelen op hun plaats hebt gesleept die door geen enkele verbinding worden vastgehouden.", + "help.component.create": "Voegt een nieuw, leeg component toe aan het ontwerp, binnen het actieve component tenzij je een andere bovenliggende kiest. Gebruik dit om een nieuw onderdeel van een samenstelling een eigen plek te geven voor lichamen en schetsen.", "help.component.finish_edit": "Slaat het ter plaatse bewerkte onderdeel op, keert terug naar de bijbehorende samenstelling en werkt de samenstelling ermee bij. Gebruik dit zodra de wijzigingen aan het gekoppelde onderdeel klaar zijn.", + "help.component.from_bodies": "Maakt van elk geselecteerd lichaam een eigen component, genoemd naar het lichaam. Gebruik dit wanneer een ontwerp van losse lichamen een samenstelling moet worden waarvan de onderdelen verbonden en verplaatst kunnen worden.", + "help.component.insert_design": "Voegt een ander opgeslagen ontwerp in als component, gekoppeld aan zijn bestand, met de massieve lichamen geplaatst zoals ze daar staan. Gebruik dit om een onderdeel in een samenstelling te hergebruiken en latere wijzigingen over te nemen met Koppelingen bijwerken.", + "help.component.properties": "Toont het volume, de massa, het zwaartepunt en de traagheidsmomenten van een component, op basis van de materialen van de lichamen. Gebruik dit om gewicht en balans van een samenstelling te controleren.", + "help.component.revert_position": "Zet onderdelen die je met de hand hebt verplaatst terug waar ze waren en laat verplaatsingen vervallen die niet zijn vastgelegd. Gebruik dit om proefverplaatsingen in een samenstelling ongedaan te maken.", + "help.component.update_links": "Leest de gekoppelde ontwerpbestanden opnieuw in en vervangt lichamen die zijn gewijzigd. Gebruik dit nadat je een onderdeel hebt bewerkt dat deze samenstelling gebruikt.", + "help.config.configure": "Voegt parameters, kenmerken, materialen of uiterlijken toe als kolommen van de configuratietabel. Gebruik dit om varianten van één ontwerp te maken, zoals maten van een onderdeel.", + "help.config.preview": "Controleert of een rij van de configuratietabel opnieuw opgebouwd kan worden, zonder het ontwerp of de geschiedenis te wijzigen. Gebruik dit voordat je overschakelt naar een variant waarvan je niet zeker bent.", + "help.config.rules": "Toont de regels van de configuratietabel, de rijen en welke rij actief is. Gebruik dit om te zien hoe de varianten van een ontwerp zijn ingesteld.", + "help.config.table": "Toont de configuratietabel: één rij per variant, één kolom per parameter of kenmerk, en of het ontwerp nog overeenkomt met de actieve rij. Gebruik dit om de varianten van een ontwerp door te nemen.", "help.connect_note": "Start elke assistent opnieuw om de SolidCraft-server te laden. Van elk bestand is vóór de wijziging een back-up gemaakt met de extensie .bak.", "help.connect_title": "AI-assistenten verbinden", + "help.construct.axis.edge": "Maakt een constructieas langs een rechte rand of schetslijn. Gebruik dit als as voor een revolve, een cirkelvormig patroon of een verbinding.", + "help.construct.axis.normal_to_face": "Maakt een constructieas door een punt, loodrecht op een vlak. Gebruik dit als as die recht uit een oppervlak steekt.", + "help.construct.axis.through_cylinder": "Maakt een constructieas door het midden van een cilinder-, kegel- of torusvlak, of van een cirkelvormige rand. Gebruik dit om kenmerken rond een rond onderdeel te draaien of te herhalen.", + "help.construct.axis.two_planes": "Maakt een constructieas waar twee vlakken samenkomen. Gebruik dit wanneer de benodigde as de lijn tussen twee vlakken is.", + "help.construct.axis.two_points": "Maakt een constructieas door twee punten, hoekpunten of schetspunten. Gebruik dit voor een as die tussen twee bekende posities loopt.", + "help.construct.plane.along_path": "Maakt een constructievlak loodrecht op een rand of curve, op een positie erlangs. Gebruik dit om een profiel voor een sweep of een doorsnede van een pad te schetsen.", + "help.construct.plane.at_angle": "Maakt een constructievlak dat onder een hoek is gedraaid ten opzichte van een vlak, om een as of rand. Gebruik dit voor schuine schetsen, zoals een hoekbeugel.", + "help.construct.plane.midplane": "Maakt een constructievlak halverwege twee vlakken, of dat de hoek tussen twee vlakken halveert waar ze onder een hoek samenkomen. Gebruik dit als symmetrievlak om te spiegelen.", + "help.construct.plane.offset": "Maakt een constructievlak evenwijdig aan een vlak, op een afstand of door een punt. Gebruik dit om op een vaste hoogte boven een vlak te schetsen.", + "help.construct.plane.perpendicular": "Maakt een constructievlak dat een lijn of rand bevat en loodrecht op een vlak staat. Gebruik dit om rechtop op een model te schetsen, langs een rand.", + "help.construct.plane.tangent": "Maakt een constructievlak dat een cilinder, kegel, bol of torus in een punt raakt. Gebruik dit om op de zijkant van een rond onderdeel te schetsen.", + "help.construct.plane.three_points": "Maakt een constructievlak door drie punten. Gebruik dit voor een vlak dat door posities op het model wordt bepaald, zoals drie hoeken.", + "help.construct.plane.two_edges": "Maakt een constructievlak door twee rechte randen of lijnen die in één vlak liggen. Gebruik dit voor een vlak door een onderdeel, bijvoorbeeld door twee tegenover elkaar liggende randen.", + "help.construct.point.along_path": "Maakt een constructiepunt op een positie langs een rand of curve. Gebruik dit als referentie ergens op een pad.", + "help.construct.point.center": "Maakt een constructiepunt in het midden van een cirkelvormige rand, een schetscirkel of boog, een bol of een torus. Gebruik dit als referentie voor gaten, assen en verbindingen.", + "help.construct.point.edge_plane": "Maakt een constructiepunt waar een rand of lijn door een vlak gaat. Gebruik dit om te markeren waar een pad een vlak kruist.", + "help.construct.point.three_planes": "Maakt een constructiepunt waar drie vlakken samenkomen. Gebruik dit als hoekreferentie, ook waar het model geen hoekpunt heeft.", + "help.construct.point.two_edges": "Maakt een constructiepunt waar twee randen of lijnen elkaar ontmoeten of het dichtst bij elkaar komen. Gebruik dit als referentie op een virtuele hoek.", + "help.construct.point.vertex": "Maakt een constructiepunt op een hoekpunt of schetspunt. Gebruik dit als referentie die blijft bestaan wanneer de schets of het lichaam eromheen verborgen is.", + "help.contact.check": "Toont de paren onderdelen in contactsets die in hun huidige positie door elkaar heen gaan. Gebruik dit om botsingen te vinden voordat je verbindingen aanstuurt of een bewegingsstudie uitvoert.", + "help.contact.create": "Groepeert twee of meer onderdelen in een contactset, waarvan de lichamen niet door elkaar heen kunnen gaan terwijl verbindingen bewegen. Gebruik dit voor onderdelen die moeten stoppen wanneer ze elkaar raken, zoals een hendel tegen zijn aanslag.", + "help.contact.disable_all": "Schakelt contact uit, zodat aangestuurde verbindingen en bewegingsstudies niet meer stoppen waar onderdelen elkaar raken. Gebruik dit om onderdelen weer vrij te verplaatsen, of om een grote samenstelling responsief te houden.", + "help.contact.edit": "Wijzigt een contactset: de onderdelen erin, de naam of de onderdrukking; de set kan ook worden verwijderd. Gebruik dit om aan te passen welke onderdelen niet door elkaar heen kunnen gaan.", + "help.contact.enable_all": "Zet elk onderdeel van de samenstelling in één contactset, zodat geen twee onderdelen door elkaar heen kunnen gaan terwijl verbindingen bewegen. Gebruik dit voor een snelle controle van een heel mechanisme.", + "help.contact.enable_sets": "Schakelt contact in voor de contactsets die je hebt gedefinieerd, zodat aangestuurde verbindingen en bewegingsstudies stoppen waar hun onderdelen door elkaar heen zouden gaan. Gebruik dit om je sets terug te halen nadat je contact hebt uitgeschakeld.", + "help.contact.list": "Toont de contactsets van het ontwerp, de onderdelen in elke set en of contact aan staat. Gebruik dit om te controleren hoe contact is ingesteld voordat je de samenstelling aanstuurt.", + "help.document.inspect": "Toont een overzicht van het ontwerp: de componenten, lichamen, schetsen, kenmerken en parameters. Gebruik dit om houvast te krijgen in een ontwerp dat je niet zelf hebt gebouwd.", + "help.document.units": "Wijzigt de eenheden waarin het ontwerp lengtes toont, zoals mm of inch. De geometrie behoudt zijn grootte; alleen de weergave en gewone getallen volgen.", + "help.edit.checkpoint": "Onthoudt het ontwerp, de actieve schets en de geschiedenis voor ongedaan maken zoals ze nu zijn. Gebruik dit voordat je iets riskants probeert waarvan je mogelijk wilt terugkeren.", + "help.edit.restore_checkpoint": "Keert terug naar een controlepunt, inclusief de geschiedenis voor ongedaan maken. Gebruik dit om alles wat sinds het controlepunt is gedaan in één stap te laten vervallen.", + "help.explode.create": "Maakt een explosieweergave die de onderdelen van het midden van de samenstelling af duwt, of volgens de verplaatsingen die je instelt. Gebruik dit om te laten zien hoe een samenstelling in elkaar zit, voor handleidingen of presentaties.", "help.face.delete": "Verwijdert de geselecteerde vlakken en laat de aangrenzende vlakken de opening sluiten, of laat de opening open als oppervlak. Gebruik dit om een afgeronde rand, een gat of een nok die in de weg zit te verwijderen.", + "help.file.insert_mesh": "Voegt een 3MF-, STL-, OBJ-, PLY- of glTF-bestand als meshlichamen toe aan het ontwerp in een nieuw component. Gebruik dit om een gedownload of gescand model als referentie naast je ontwerp te plaatsen.", + "help.file.insert_step": "Voegt een STEP-, IGES-, FreeCAD- of Rhino-bestand als massieve lichamen toe aan het ontwerp in een nieuw component; de onderdelen van een samenstelling worden geneste componenten. Gebruik dit om modellen van leveranciers of andere CAD-programma's te importeren.", + "help.file.save_mesh": "Slaat lichamen op als STL-, 3MF- of OBJ-mesh, met de gladheid van gebogen vlakken die je kiest. Gebruik dit om onderdelen voor te bereiden voor 3D-printen of voor programma's die meshes lezen.", + "help.file.send_to_slicer": "Slaat de geselecteerde lichamen op als 3MF-bestand en opent het in de slicer die je kiest. Gebruik dit om in één stap van model naar printen te gaan.", "help.font_details": "De interface gebruikt de ingebouwde lettertypen van egui (Ubuntu Light en Hack, onder de Ubuntu Font Licence en de MIT/Bitstream Vera-licentie; Noto Emoji en het emoji-pictogramlettertype, onder de OFL en MIT). Schetstekst gebruikt Liberation Sans (SIL OFL 1.1). Het optionele lettertypepakket van The Crafting Apps, craft-fonts (BIZ UDPGothic, Shippori Mincho, BIZ UDMincho, Noto Sans CJK SC, Noto Sans Arabic; SIL OFL 1.1), wordt niet meegeleverd in SolidCraft-builds.", "help.fonts": "Lettertypen", + "help.frame.member": "Plaatst een profiel, zoals een buis of een aluminium extrusieprofiel, langs de lijnen van een kaderschets, één constructiedeel per lijn. Gebruik dit om kaders, rekken en gelaste constructies te bouwen.", + "help.inspect.accessibility": "Kleurt de vlakken die een gereedschap vanuit één richting kan bereiken, en die het niet kan bereiken. Gebruik dit om een onderdeel te controleren op bewerking of gieten vanaf die kant.", + "help.inspect.center_of_mass": "Toont het zwaartepunt van lichamen, elk afzonderlijk en allemaal samen, bij uniforme dichtheid. Gebruik dit om balans te controleren en te zien waar een onderdeel kantelt.", + "help.inspect.colored_components": "Geeft elk component een eigen tijdelijke kleur, zonder uiterlijken te wijzigen. Gebruik dit om de onderdelen van een samenstelling in één oogopslag te onderscheiden.", + "help.inspect.compare": "Vergelijkt twee lichamen en toont waar het ene materiaal heeft dat het andere niet heeft, met volume en afwijking. Gebruik dit om een gewijzigd of geïmporteerd onderdeel met het origineel te vergelijken.", + "help.inspect.continuity": "Controleert hoe vloeiend vlakken langs hun randen aansluiten: gaten, knikken of krommingssprongen, en kleurt de naden. Gebruik dit om te vinden waar oppervlakken niet netjes op elkaar aansluiten.", + "help.inspect.curvature_comb": "Tekent kammen langs curven, randen of vlakken waarvan de tanden tonen hoe sterk ze buigen. Gebruik dit om vlakke plekken, knikken en ongelijkmatige kromming op te sporen.", + "help.inspect.curvature_map": "Kleurt vlakken naar hoe sterk ze krommen, rood waar de straal klein is. Gebruik dit om krappe plekken te vinden en te beoordelen hoe glad een oppervlak is.", + "help.inspect.draft": "Kleurt vlakken naar hun hellingshoek ten opzichte van een trekrichting en markeert die met te weinig helling. Gebruik dit om een onderdeel te controleren vóór het spuitgieten of gieten.", + "help.inspect.environment_map": "Toont het model met een spiegelende, glanzende afwerking. Gebruik dit om het verloop van oppervlakken te beoordelen aan de hand van de weerspiegelingen die erover lopen.", + "help.inspect.interference": "Vindt lichamen die elkaar overlappen en toont elke overlap met zijn volume. Gebruik dit om een samenstelling te controleren op onderdelen die botsen.", + "help.inspect.isocurve": "Tekent de isoparametrische curven van vlakken, het raster waarop het oppervlak is opgebouwd. Gebruik dit om te zien hoe een oppervlak verloopt en waar het samenklontert.", + "help.inspect.measure": "Meet lengtes, afstanden, hoeken, oppervlakten en stralen tussen wat je kiest, of het volume en de massa van lichamen. Gebruik dit om maten en speling te controleren.", + "help.inspect.minimum_radius": "Zoekt de kleinste krommingsstraal van curven, randen of vlakken en waar die zit. Gebruik dit om te controleren of een gereedschap of spuitmond de vorm kan volgen.", + "help.inspect.section": "Snijdt de weergave open langs een vlak en arceert de snijvlakken, zonder het model te wijzigen. Gebruik dit om in onderdelen en samenstellingen te kijken.", + "help.inspect.zebra": "Toont zwart-witte strepen die op de vlakken van het model weerspiegelen. Gebruik dit om gladheid te beoordelen: strepen die bij een rand breken of knikken wijzen op een slechte overgang.", + "help.joint.as_built": "Verbindt twee onderdelen waar ze al staan, zonder er een te verplaatsen, en stelt in hoe ze nog mogen bewegen. Gebruik dit voor onderdelen die je al op hun definitieve plek hebt geplaatst.", + "help.joint.cam": "Koppelt een draaiende nok aan een volger, zodat de volger schuift of zwenkt terwijl de omtrek van de nok eronder doorgaat. Gebruik dit voor nokken, excenters en klepaandrijvingen.", + "help.joint.create": "Verbindt twee onderdelen op de verbindingsoorsprongen die je kiest en verplaatst het tweede onderdeel op zijn plaats; het verbindingstype bepaalt hoe ze nog mogen bewegen. Gebruik dit om een samenstelling te bouwen waarvan de onderdelen bewegen als het echte mechanisme.", + "help.joint.drive": "Stelt de hoek of afstand van een verbinding in en beweegt de onderdelen mee, binnen de limieten; gekoppelde verbindingen en gesloten lussen volgen. Gebruik dit om te controleren of een mechanisme beweegt zoals bedoeld.", + "help.joint.gear": "Koppelt twee draaiende verbindingen met een overbrengingsverhouding, zodat het aansturen van de ene de andere laat draaien. Gebruik dit voor recht vertande en inwendig vertande tandwielen, met de verhouding uit steekcirkelstralen of tandenaantallen.", + "help.joint.limits": "Stelt in hoever een verbinding mag draaien of schuiven, als minimum en maximum, of wist die limieten. Gebruik dit om een scharnier, schuifverbinding of hendel binnen zijn echte bewegingsbereik te houden.", + "help.joint.motion_link": "Koppelt twee verbindingen met een verhouding en een offset, zodat het aansturen van de ene de andere beweegt. Gebruik dit voor een vaste relatie tussen verbindingen die Tandwiel, Tandheugel en rondsel of Schroef niet dekken.", + "help.joint.origin": "Plaatst een benoemde verbindingsoorsprong op een onderdeel: in het midden van een vlak of rond uiteinde, het midden van een rand, een hoek of een punt. Gebruik dit om voor te bereiden waar verbindingen vastzitten; een oorsprong op een vlak volgt dat vlak wanneer het onderdeel van grootte verandert.", + "help.joint.rack_pinion": "Koppelt een draaiend rondsel aan een schuivende tandheugel, zodat de tandheugel verschuift over de lengte van de steekcirkel die het rondsel afrolt. Gebruik dit voor tandheugelaandrijvingen, besturingen en lineaire tafels.", + "help.joint.reach": "Beweegt een punt op een onderdeel naar een doel door de verbindingen in de keten ervan te wijzigen, binnen hun limieten. Gebruik dit om een mechanisme te positioneren aan de hand van waar het uiteinde moet komen, zoals de punt van een robotarm.", + "help.joint.rigid_group": "Vergrendelt de geselecteerde onderdelen zoals ze zijn, zodat ze als één geheel bewegen. Gebruik dit voor onderdelen die bij elkaar horen en waartussen geen beweging is.", + "help.joint.screw": "Koppelt draaien en schuiven via een spoed, zodat elke omwenteling over die afstand opschuift. Gebruik dit voor aandrijfspindels, bouten en schroefspindels.", + "help.library.apply": "Past een materiaal of uiterlijk uit een lokaal bibliotheekbestand toe op lichamen, vlakken of componenten. Gebruik dit om materialen die je uit een ander ontwerp hebt opgeslagen opnieuw te gebruiken.", + "help.library.export": "Slaat de materialen en uiterlijken van het ontwerp op in een bibliotheekbestand. Gebruik dit om ze te delen of in andere ontwerpen te hergebruiken.", + "help.library.inspect": "Controleert een bibliotheekbestand en toont de materialen en uiterlijken erin, zonder het ontwerp te wijzigen. Gebruik dit om te zien wat een bibliotheek bevat voordat je die toepast.", "help.licences": "Licenties", "help.material.assign": "Stelt in waarvan de geselecteerde lichamen gemaakt zijn, zoals staal, aluminium of ABS. Gebruik dit zodat massa en gewicht overeenkomen met het echte onderdeel.", + "help.material.properties.validate": "Controleert een materiaalgegevenskaart op consistente getallen en ontbrekende waarden. Gebruik dit voordat je op de gegevens vertrouwt voor massa of stijfheid.", + "help.mesh.convert": "Bouwt een meshlichaam, zoals een STL, opnieuw op als bewerkbaar massief lichaam met platte en cilindrische vlakken. Gebruik dit om geïmporteerde mechanische onderdelen te wijzigen als een gemodelleerd lichaam.", "help.mit": "MIT-licentie", + "help.motion.study": "Stelt verbindingswaarden in op stappen langs een tijdlijn en speelt ze af als animatie. Gebruik dit om een mechanisme door een reeks bewegingen te zien gaan en onderweg de speling te controleren.", + "help.motion.trace": "Volgt het pad dat een punt op een onderdeel aflegt terwijl een bewegingsstudie wordt afgespeeld of een verbinding wordt aangestuurd, en bewaart het als 3D-schetscurve wanneer je bevestigt. Gebruik dit om te zien waar een mechanisme reikt, of om op het pad voort te bouwen.", "help.notice": "NOTICE", + "help.occurrence.ground": "Zet een onderdeel vast, of maakt het weer vrij, zodat verbindingen de andere onderdelen eromheen bewegen. Gebruik dit voor het frame of de basis waarop de rest van de samenstelling is gebouwd.", "help.occurrence.move": "Verplaatst een onderdeel van een samenstelling alleen langs de vrijheidsgraden die zijn verbindingen toestaan. Selecteer een vlak van het onderdeel en sleep dan een beschikbare pijl of boog.", "help.parameters.change": "Opent de tabel met benoemde waarden en expressies die de maten van het ontwerp aansturen. Gebruik dit om een maat op één plek te wijzigen, zodat elk kenmerk dat die maat gebruikt vanzelf meegaat.", + "help.parameters.export": "Slaat de parameters van het ontwerp op als CSV of JSON. Gebruik dit om ze in een spreadsheet te bewerken of naar een ander ontwerp over te brengen.", + "help.parameters.graph": "Toont welke parameters en kenmerken welke andere gebruiken, en eventuele lussen of fouten daartussen. Gebruik dit om te zien wat een wijziging van een parameter beïnvloedt.", + "help.parameters.import": "Leest parameters uit CSV of JSON in het ontwerp, alle of geen. Gebruik dit om waarden uit een spreadsheet of een ander ontwerp over te nemen.", + "help.parameters.move": "Verplaatst een gebruikersparameter vóór of na een andere in de lijst. Gebruik dit om samenhangende parameters bij elkaar te houden.", + "help.parts.fastener": "Voegt een standaard schroef, bout, moer, ring, pen of lager toe in de maat die je kiest. Op een gat geplaatst, komt het met een verbinding in het gat te zitten, zodat je bevestigingsmiddelen niet zelf hoeft te modelleren.", + "help.parts.insert": "Voegt een standaardonderdeel in met de familie, maat en lengte die je kiest. Op de rand of wand van een gat geplaatst, komt het met een starre verbinding op het gat te zitten.", + "help.parts.library": "Opent de standaardonderdelen: schroeven, bouten, moeren, ringen, stelschroeven, paspennen en lagers in standaardmaten. Gebruik dit om een standaardonderdeel toe te voegen in plaats van het zelf te modelleren.", + "help.parts.resize": "Wijzigt de maat of lengte van een ingevoegd standaardonderdeel en bouwt het opnieuw op; de verbindingen houden het op zijn plaats. Gebruik dit wanneer een schroef te lang blijkt of een gat een andere maat nodig heeft.", + "help.plastic.assign_rule": "Wijst een kunststofregel toe aan lichamen, zodat hun kunststofkenmerken de aanzet en speling ervan als standaard overnemen. Gebruik dit om een spuitgegoten onderdeel één keer in te stellen voor al zijn kenmerken.", + "help.plastic.boss": "Voegt een schroefnok toe op een vlak: een pilaar met optioneel een gat, aanzet, wortelafronding en ondersteunende ribben. Gebruik dit voor schroefpunten in spuitgegoten of geprinte behuizingen.", + "help.plastic.lip": "Voegt een lip of een bijpassende groef toe langs de rand van een geschaald lichaam. Gebruik dit zodat twee behuizingshelften op elkaar passen en strak sluiten.", + "help.plastic.manage_rules": "Maakt en bewerkt kunststofregels: materiaal, wanddikte, stralen, speling en aanzet. Gebruik ze om de kenmerken van een spuitgegoten onderdeel consistent te houden.", + "help.plastic.rest": "Voegt een rustvlak toe op een vlak: een ronde of rechthoekige verhoging, massief of hol, met aanzet. Gebruik dit als steun of afstandhouder in een spuitgegoten behuizing.", + "help.plastic.snap_fit": "Voegt een snaphaak toe op een vlak, met lengte, dikte en klikrand. Gebruik dit om onderdelen zonder schroeven aan elkaar te klikken.", "help.pointcloud.insert": "Voeg een PLY-, XYZ- of PTS-scan in als puntenwolk naast het model. Gebruik deze als referentie bij het bouwen van een model op basis van gemeten gegevens.", + "help.sheet.convert": "Maakt van een plaat of geschaald lichaam van gelijkmatige dikte een plaatwerklichaam, met buigingen op de randen die je kiest. Gebruik dit om een bestaand onderdeel ontvouwbaar te maken.", + "help.sheet.export_bend_table": "Slaat de buigtabel van een plaatwerklichaam op als CSV of JSON: hoeken, stralen en buigposities in de uitslag. Gebruik dit voor de kantbank of de werkplaats.", + "help.sheet.export_dxf": "Slaat de uitslag op als DXF, met contouren, buiglijnen en optionele buigopmerkingen. Gebruik dit voor laser-, plasma- of waterstraalsnijden.", + "help.sheet.flange": "Voegt een flens toe: een basisplaat uit een schets, een wand langs een open schetscontour, of een gebogen wand op plaatranden. Gebruik dit om plaatwerkonderdelen op te bouwen.", + "help.sheet.flat_pattern": "Maakt de uitslag van een plaatwerklichaam, met zijn afmetingen, uitsnijdingen en buiglijnen. Gebruik dit om de plano te controleren voordat je hem snijdt.", + "help.sheet.fold": "Vouwt een plaat langs een schetslijn onder een hoek en met een straal. Gebruik dit om een vlakke plaat te vouwen nadat je er kenmerken in hebt gesneden.", + "help.sheet.hem": "Vouwt een plaatrand terug op zichzelf. Gebruik dit voor veilige, stijve randen zonder scherpe bramen.", + "help.sheet.manage_rules": "Maakt en bewerkt plaatwerkregels: dikte, buigstraal, K-factor, vrijsnijdingen en spleten. Gebruik ze om af te stemmen op het materiaal en het gereedschap van je werkplaats.", + "help.sheet.path_flange": "Voegt een reeks flenzen toe langs een plaatrand, volgens een open keten van schetslijnen. Gebruik dit voor profielen zoals een U- of Z-vorm in één stap.", + "help.sheet.perforate": "Voegt een veld van gaten toe over de hele vlakke plaat, in een verspringend of vierkant patroon. Gebruik dit voor ventilatieopeningen, roosters en lichtere panelen.", + "help.sheet.refold": "Vouwt een ontvouwen plaatwerklichaam weer op. Gebruik dit nadat je kenmerken aan de vlakke plaat hebt toegevoegd.", + "help.sheet.unfold": "Ontvouwt alle buigingen van een plaatwerklichaam. Gebruik dit om gaten of sneden toe te voegen die over een buiging heen moeten lopen, en vouw daarna opnieuw.", + "help.sketch.arc.center_point": "Tekent een cirkelboog rond een middelpunt, van een beginpunt naar een eindpunt. Gebruik dit wanneer het middelpunt van de boog bekend is, zoals een straal rond een gat of een draaipunt.", + "help.sketch.arc.tangent": "Tekent een boog die begint op het uiteinde van een lijn of boog en er tangentieel op aansluit. Gebruik dit om een keten voort te zetten met een vloeiende bocht in plaats van een hoek.", + "help.sketch.arc.three_point": "Tekent een boog van een beginpunt naar een eindpunt door een derde punt. Gebruik dit wanneer de boog door gegeven punten moet gaan in plaats van rond een bekend middelpunt.", + "help.sketch.arc3d": "Tekent een exacte cirkelboog in de ruimte door drie punten, met 3D-schets aan. Gebruik dit voor bochten in paden en geleiders die het schetsvlak verlaten.", + "help.sketch.auto_constrain": "Voegt de beperkingen toe die de schets al bijna heeft, zoals horizontaal, evenwijdig of samenvallend, en voorziet hem daarna van maten tot hij volledig bepaald is. Gebruik dit om een ruw getekende schets in één stap vast te leggen.", + "help.sketch.auto_constrain_from_datum": "Werkt zoals Autom. beperken, maar meet de posities vanaf een referentiepunt dat je kiest in plaats van vanaf de oorsprong. Gebruik dit wanneer het onderdeel vanuit één referentiepunt is opgezet, zoals een hoek of een gat.", + "help.sketch.blend_curve": "Verbindt de uiteinden van twee curven met een vloeiende spline die tangentieel aansluit op beide, of ook krommingscontinu als je dat kiest. Gebruik dit om een opening tussen curven zonder knik te overbruggen.", + "help.sketch.break": "Splitst een curve in stukken waar andere curven hem kruisen, of op punten die je erop plaatst. Gebruik dit wanneer een deel van een curve een eigen beperking nodig heeft of apart moet worden verwijderd.", + "help.sketch.cancel": "Verlaat de schets en verwerpt alles wat is gewijzigd sinds hij is gemaakt of geopend voor bewerking. Met Ongedaan maken komen de wijzigingen terug.", + "help.sketch.centerline": "Schakelt lijnen om tussen gewone lijnen en hartlijnen, assen die profielen weglaten. Gebruik een hartlijn als as voor een revolve, een spiegeling of een symmetriebeperking.", + "help.sketch.chamfer.distance_angle": "Schuint de hoek tussen twee lijnen af, ingesteld met een afstand langs de eerste lijn en een hoek. Gebruik dit wanneer een tekening de afschuining opgeeft als lengte en hoek, zoals 2 mm × 30°.", + "help.sketch.chamfer.equal_distance": "Schuint de hoek tussen twee lijnen af met dezelfde afstand terug langs beide. Gebruik dit voor de gebruikelijke afschuining van 45° op een contour.", + "help.sketch.chamfer.two_distance": "Schuint de hoek tussen twee lijnen af, ingesteld met een aparte afstand langs elke lijn. Gebruik dit voor afschuiningen die niet 45° zijn, opgegeven als twee lengtes.", + "help.sketch.circle.center": "Tekent een cirkel vanuit zijn middelpunt en diameter. Gebruik dit voor gaten, nokken en andere ronde kenmerken waarvan het middelpunt bekend is.", + "help.sketch.circle.three_point": "Tekent een cirkel door drie punten. Gebruik dit wanneer de cirkel door gegeven punten moet gaan, bijvoorbeeld van bestaande geometrie.", + "help.sketch.circle.three_tangent": "Tekent een cirkel die drie lijnen, cirkels of bogen raakt. Gebruik dit om een cirkel in de ruimte tussen drie curven te passen, zoals de ingeschreven cirkel van een driehoek.", + "help.sketch.circle.two_point": "Tekent een cirkel vanuit de twee uiteinden van zijn diameter. Gebruik dit wanneer de randpunten van de cirkel bekend zijn in plaats van het middelpunt.", + "help.sketch.circle.two_tangent": "Tekent een cirkel van een opgegeven grootte die twee lijnen, cirkels of bogen raakt. Gebruik dit voor een afronding met vaste straal tussen twee curven.", + "help.sketch.conic": "Tekent een kegelsnede tussen twee eindpunten, gevormd door een toppunt en een rho-waarde. Gebruik dit voor vloeiende overgangen waarvan je de volheid wilt afstellen, van vlak tot bijna een scherpe hoek.", + "help.sketch.constrainer": "Past de beperking toe die een of twee gekozen curven al bijna hebben, zoals horizontaal, evenwijdig, tangentieel of gelijk. Gebruik dit om een met de hand getekende schets relatie voor relatie op te ruimen.", + "help.sketch.constraint.coincident": "Laat een punt samenvallen met een ander punt, een curve of een schetsas. Gebruik dit om openingen in een profiel te sluiten of een punt op een lijn te zetten.", + "help.sketch.constraint.collinear": "Laat twee lijnen op één rechte lijn liggen. Gebruik dit om onderbroken randen uit te lijnen, zoals beide zijden van een inkeping.", + "help.sketch.constraint.concentric": "Geeft twee cirkels of bogen hetzelfde middelpunt. Gebruik dit voor ringen, verzinkingen en assen met schouders.", + "help.sketch.constraint.curvature": "Laat twee curven die in een eindpunt samenkomen met dezelfde kromming (G2) doorlopen. Gebruik dit waar een tangentiële aansluiting niet vloeiend genoeg is, bijvoorbeeld op zichtbare oppervlakken.", + "help.sketch.constraint.equal": "Maakt lijnen even lang, of cirkels en bogen even groot van straal. Gebruik dit zodat één maat meerdere overeenkomende kenmerken aanstuurt.", + "help.sketch.constraint.fix": "Zet punten of curven vast waar ze zijn, of maakt ze weer los. Gebruik dit voor geometrie die niet mag bewegen wanneer je de schets sleept of oplost.", + "help.sketch.constraint.horizontal_vertical": "Maakt een lijn, of de lijn tussen twee punten, horizontaal of verticaal, afhankelijk van wat het dichtst bij ligt. Gebruik dit om een contour recht op de schetsassen te zetten.", + "help.sketch.constraint.midpoint": "Zet een punt op het midden van een lijn, of laat de middelpunten van twee lijnen samenvallen. Gebruik dit om geometrie op een rand te centreren.", + "help.sketch.constraint.parallel": "Maakt twee lijnen evenwijdig. Gebruik dit voor zijden die evenwijdig moeten blijven wanneer andere maten veranderen.", + "help.sketch.constraint.perpendicular": "Laat twee lijnen een rechte hoek maken. Gebruik dit voor rechte hoeken tussen lijnen die niet op de schetsassen liggen.", + "help.sketch.constraint.polygon": "Maakt van een gesloten keten van lijnen een regelmatige veelhoek: gelijke zijden, met elke hoek op één cirkel. Gebruik dit om een met de hand getekende veelhoek regelmatig te maken en te houden.", + "help.sketch.constraint.symmetry": "Laat twee punten of curven elkaars spiegelbeeld zijn ten opzichte van een lijn of rond een punt. Gebruik dit om een profiel symmetrisch te houden wanneer je één zijde wijzigt.", + "help.sketch.constraint.tangent": "Laat twee curven elkaar tangentieel raken, zoals een lijn en een boog. Gebruik dit voor vloeiende aansluitingen zonder knik.", + "help.sketch.construction": "Schakelt curven om tussen normale geometrie en constructiegeometrie, die profielen weglaten. Gebruik constructiecurven als hulp voor indeling, referenties en maten.", "help.sketch.construction_line": "Maak een oneindige constructielijn door twee punten. Gebruik deze om een schets op te zetten zonder profielen te splitsen.", "help.sketch.construction_line.angle": "Maak een oneindige constructielijn onder een hoek ten opzichte van de X-as van de schets. Kies het punt en typ de hoek, of kies de richting ervan.", "help.sketch.construction_line.bisector": "Kies twee elkaar snijdende rechte schetslijnen en kies dan een van beide bissectrices. Het resultaat volgt zijn bronlijnen.", @@ -1412,11 +1564,71 @@ "help.sketch.construction_line.tangent_external": "Kies een cirkel of cirkelboog en dan een extern punt en een oplossing voor de raaklijn. De voorbeelden tonen alleen raakpunten op de getekende boog.", "help.sketch.construction_line.tangent_two": "Kies twee cirkels of cirkelbogen en kies dan een gemeenschappelijke raaklijn in het voorbeeld. De raakpunten moeten op de getekende krommen liggen.", "help.sketch.construction_line.vertical": "Maak een oneindige verticale constructielijn door een punt. De verticale beperking blijft gelden bij latere bewerkingen.", + "help.sketch.copy": "Kopieert de geselecteerde schetscurven en -punten, samen met de beperkingen en maten daartussen. Plak ze in deze schets of in een andere.", + "help.sketch.create": "Start een nieuwe 2D-schets op een vlak of op een plat vlak van het model. Elke extrusie, revolve en sweep begint met een schets.", + "help.sketch.cut": "Kopieert de geselecteerde schetsgeometrie en verwijdert die uit de schets, in één stap om ongedaan te maken. Plak haar elders in deze schets of in een andere.", + "help.sketch.dimension": "Voegt een aandrijvende maat toe: een lengte, afstand, hoek, straal of diameter, afhankelijk van wat je kiest. Typ een waarde of een parameternaam en de schets volgt die.", + "help.sketch.edit": "Opent een bestaande schets voor bewerking. De kenmerken die erop zijn gebouwd worden bijgewerkt wanneer je de schets voltooit.", + "help.sketch.ellipse": "Tekent een ellips vanuit zijn middelpunt, het uiteinde van zijn lange as en een punt aan de korte zijde. Gebruik dit voor ovale openingen, nokken en andere langgerekte ronde vormen.", "help.sketch.equation_curve": "Tekent een curve uit formules: x(t) en y(t), of y = f(x), over een bereik. De formules kunnen parameters gebruiken en de curve volgt ze: gebruik dit voor een sinusgolf, een spiraal of de evolvente van een tandwieltand.", + "help.sketch.extend": "Verlengt een lijn of boog tot de dichtstbijzijnde curve in de weg, of laat twee curven aangroeien tot ze elkaar ontmoeten. Gebruik dit om openingen in een profiel te sluiten na het trimmen of bewerken.", + "help.sketch.fillet": "Rondt de hoek waar twee lijnen of bogen samenkomen af met een boog met een opgegeven straal. Meerdere hoeken die samen worden afgerond delen één straal.", + "help.sketch.finish": "Verlaat de schets en keert terug naar het model. De gesloten profielen zijn dan klaar voor Extruderen, Revolve en andere kenmerken.", + "help.sketch.finish_auto_constrain": "Voert Autom. beperken uit op de schets en voltooit hem daarna. Gebruik dit om een schets in één stap volledig bepaald achter te laten.", + "help.sketch.fit_curves_to_section": "Snijdt een mesh- of massief lichaam met het schetsvlak en past lijnen en bogen aan op de doorsnede. Gebruik dit om een gescand of geïmporteerd onderdeel opnieuw op te bouwen als bewerkbare schets.", + "help.sketch.include_3d": "Brengt modelranden, hoekpunten en curven van andere schetsen als gekoppelde referenties in deze schets, waarbij wat buiten het schetsvlak ligt op dat vlak wordt afgeplat. Gebruik dit om voort te bouwen op geometrie die niet in het schetsvlak ligt.", + "help.sketch.infinite": "Schakelt lijnen om tussen normale lijnen en oneindige constructielijnen die door de hele weergave lopen. Gebruik ze als indelingsreferenties die nooit te kort zijn.", + "help.sketch.insert_dxf": "Leest de curven van een DXF-tekening in een schets, geschaald, gedraaid en geplaatst waar je instelt. Gebruik dit om voort te bouwen op een 2D-tekening uit een ander programma, zoals een lasersnijcontour.", + "help.sketch.insert_svg": "Leest de paden van een SVG-tekening in een schets, geschaald, gedraaid en geplaatst waar je instelt. Gebruik dit om een logo of vectorcontour om te zetten in curven die je kunt extruderen.", + "help.sketch.inspect": "Opent een rapport over de geopende schets: hoeveel vrijheidsgraden er nog over zijn en of hij volledig beperkt is. Gebruik dit om te zien wat nog beweegt voordat je de schets voltooit.", + "help.sketch.intersect": "Voegt de curven of punten toe waar lichamen, vlakken, randen of andere schetsen het schetsvlak kruisen. Ze blijven gekoppeld en volgen het model wanneer dat verandert.", + "help.sketch.intersection_curve": "Maakt 3D-curven waar twee lichamen of vlakken samenkomen, eraan gekoppeld. Gebruik ze als paden of geleiders die het model volgen.", + "help.sketch.isoparametric_curve": "Maakt een 3D-curve langs de U- of V-richting van een vlak door een punt dat je kiest, gekoppeld aan het vlak. Gebruik dit als pad of splitslijn die het oppervlak volgt.", + "help.sketch.line": "Tekent verbonden lijnen punt voor punt, als open keten of gesloten contour. Het is het basisgereedschap voor de meeste profielen.", + "help.sketch.line.midpoint": "Tekent een lijn vanuit zijn middelpunt naar één uiteinde, waarbij het andere uiteinde wordt gespiegeld. Gebruik dit voor lijnen die op een punt zijn gecentreerd, zoals een symmetrische rand.", + "help.sketch.line3d": "Tekent verbonden rechte lijnen tussen punten in de ruimte, met 3D-schets aan. Gebruik dit voor paden en kaders die het schetsvlak verlaten.", + "help.sketch.mirror": "Spiegelt schetscurven en -punten over een lijn of as en voegt symmetriebeperkingen toe. Gebruik dit om de helft van een symmetrisch profiel te tekenen en het aan te vullen.", + "help.sketch.move": "Verplaatst of draait schetscurven en -punten, of een kopie ervan. Gebruik dit om geometrie op een andere plek te zetten met behoud van vorm en beperkingen.", + "help.sketch.offset": "Maakt een kopie van een keten van curven op een opgegeven afstand, aan één kant of aan beide. Gebruik dit voor wanden, contouren en spelingen; de afstand blijft een bewerkbare maat.", + "help.sketch.paste": "Plakt schetsgeometrie die je hebt gekopieerd of geknipt in de actieve schets. Plaats haar op een punt of verschuif haar met een offset.", + "help.sketch.pattern.circular": "Kopieert schetsgeometrie rond een middelpunt, over een volledige cirkel of een opgegeven hoek. Gebruik dit voor steekcirkels van bouten, spaken en tanden.", + "help.sketch.pattern.rectangular": "Kopieert schetsgeometrie in rijen en kolommen met opgegeven tussenafstanden. Gebruik dit voor roosters van gaten, sleuven of ventilatieopeningen.", + "help.sketch.point": "Plaatst een schetspunt. Gebruik dit als referentie voor maten, beperkingen, gatmiddelpunten of latere kenmerken.", + "help.sketch.point3d": "Plaatst een punt in de ruimte, met 3D-schets aan. Gebruik dit als referentie voor 3D-curven en paden.", + "help.sketch.polygon.circumscribed": "Tekent een regelmatige veelhoek rond een cirkel, bepaald door de afstand van het midden tot het midden van een zijde. Gebruik dit wanneer de sleutelwijdte bekend is, zoals bij een zeskantmoer.", + "help.sketch.polygon.edge": "Tekent een regelmatige veelhoek vanuit de twee uiteinden van één zijde. Gebruik dit wanneer de lengte en positie van een zijde bekend zijn.", + "help.sketch.polygon.inscribed": "Tekent een regelmatige veelhoek binnen een cirkel, bepaald door de afstand van het midden tot een hoek. Gebruik dit wanneer de maat over de hoeken bekend is.", + "help.sketch.project": "Brengt modelranden, vlakken, hoekpunten en curven van andere schetsen in deze schets, geprojecteerd op het vlak ervan. Ze blijven gekoppeld en volgen het model wanneer dat verandert.", + "help.sketch.project_to_surface": "Projecteert curven van andere schetsen langs de normaal van deze schets op een vlak, als gekoppelde 3D-curven. Gebruik dit om een logo, splitslijn of pad op een gebogen oppervlak te zetten.", + "help.sketch.rectangle.center": "Tekent een rechthoek vanuit zijn middelpunt en één hoek. Gebruik dit voor contouren die op een punt zijn gecentreerd, zoals de oorsprong.", + "help.sketch.rectangle.rounded": "Tekent een rechthoek waarvan alle vier de hoeken met één straal zijn afgerond. Gebruik dit voor platen, deksels en openingen met afgeronde hoeken.", + "help.sketch.rectangle.three_point": "Tekent een rechthoek vanuit één rand en een punt aan de overliggende zijde. Gebruik dit voor rechthoeken onder een hoek met de schetsassen.", + "help.sketch.rectangle.two_point": "Tekent een rechthoek vanuit twee overliggende hoeken. Gebruik dit voor de contouren van platen, blokken en uitsparingen.", + "help.sketch.scale": "Schaalt schetscurven en -punten met een factor rond een basispunt. Gebruik dit om een deel van een schets van grootte te veranderen zonder het opnieuw te tekenen.", + "help.sketch.slot.arc_center": "Tekent een gebogen sleuf met een opgegeven breedte langs een boog die is bepaald door middelpunt, begin en einde. Gebruik dit voor stelsleuven rond een draaipunt.", + "help.sketch.slot.arc_three_point": "Tekent een gebogen sleuf met een opgegeven breedte langs een boog door drie punten. Gebruik dit wanneer het pad van de sleuf door gegeven punten moet gaan.", + "help.sketch.slot.center_point": "Tekent een rechte sleuf met een opgegeven breedte vanuit zijn midden tot het middelpunt van één eindboog. Gebruik dit voor sleuven die op een punt zijn gecentreerd.", + "help.sketch.slot.center_to_center": "Tekent een rechte sleuf met een opgegeven breedte tussen de middelpunten van zijn twee eindbogen. Gebruik dit voor sleuven met maatvoering hart-op-hart, zoals stelsleuven voor schroeven.", + "help.sketch.slot.overall": "Tekent een rechte sleuf met een opgegeven breedte van het ene buitenste uiteinde tot het andere. Gebruik dit wanneer de totale lengte van de sleuf telt.", + "help.sketch.spline.control_point": "Tekent een kubische spline die wordt gevormd door controlepunten waar hij niet doorheen gaat. Gebruik dit voor vloeiende, soepele curven die je vormgeeft door de punten te verplaatsen.", + "help.sketch.spline.control_point_5": "Tekent een spline van graad 5, gevormd door controlepunten. Gebruik dit waar curven met vloeiendere kromming moeten aansluiten, bijvoorbeeld op zichtbare vormgevingsoppervlakken.", + "help.sketch.spline.fit_point": "Tekent een vloeiende spline die door elk punt gaat dat je plaatst, open of gesloten. Gebruik dit om een vorm door bekende punten na te trekken.", + "help.sketch.spline3d": "Tekent een vloeiende curve in de ruimte door punten, met 3D-schets aan. Gebruik dit voor sweeppaden en kabeltrajecten die het schetsvlak verlaten.", + "help.sketch.spun_profile": "Maakt in het schetsvlak de contour die een lichaam doorloopt wanneer het om een as draait. Gebruik dit om het silhouet van een gedraaid onderdeel te krijgen voor een revolve of een tekening.", + "help.sketch.text": "Schrijft tekst in de schets in een gekozen lettertype, hoogte en hoek; de woorden kunnen parameters volgen. Gebruik Extruderen of Embossen voor labels en belettering.", + "help.sketch.transform": "Schaalt, draait en verplaatst schetsgeometrie rond een middelpunt in één stap. Gebruik dit om een selectie in één keer te verplaatsen en van grootte te veranderen.", + "help.sketch.trim": "Verwijdert het stuk van een curve tussen de dichtstbijzijnde snijpunten rond waar je klikt. Gebruik dit om overlappende lijnen op te ruimen tot een gesloten profiel.", "help.solid.align": "Verplaatst de geselecteerde lichamen zodat een punt, vlak of werkvlak erop samenvalt met een punt, vlak of werkvlak elders. Gebruik dit om een onderdeel tegen een ander te plaatsen zonder de afstand te meten.", + "help.solid.boundary_fill": "Maakt massieve lichamen uit de gebieden die worden ingesloten door elkaar overlappende gereedschapslichamen, en vult alleen de gebieden die je kiest. Gebruik dit om een vorm op te bouwen uit de ruimte die meerdere lichamen samen begrenzen.", + "help.solid.bounding_solid": "Maakt een kubus die de geselecteerde lichamen omsluit, met een optionele marge. Gebruik dit om de totale afmetingen van een onderdeel te zien, of als het ruwe materiaal waaruit het wordt bewerkt.", + "help.solid.box": "Maakt een kubus uit een lengte, breedte en hoogte, vanaf een hoekpunt of gecentreerd op een punt. Gebruik dit voor een snel blok, een plaatshouder of een lichaam om kenmerken in te snijden.", "help.solid.chamfer": "Snijdt de geselecteerde randen af met een vlakke afschuining van de ingestelde afstand. Gebruik dit om scherpe randen te breken of het inbrengen van een pen of schroef te vergemakkelijken.", + "help.solid.coil": "Maakt een schroeflijnvormig lichaam, zoals een veer, uit een diameter, windingen, hoogte of spoed en een ronde of vierkante doorsnede. Gebruik dit voor veren, spoelen en schroeflijnvormige ribbels.", "help.solid.combine": "Voegt lichamen samen, snijdt ze uit een doellichaam of behoudt alleen het gedeelde volume, en kan de gereedschapslichamen behouden. Gebruik dit om onderdelen samen te voegen, een vorm met een andere uit te snijden of te vinden waar twee lichamen overlappen.", + "help.solid.cone": "Maakt een kegel uit een grondcirkel en een hoogte, of een afgeknotte kegel wanneer de top ook een straal heeft. Gebruik dit voor taps toelopende delen, trechters, verzinkingen en punten.", + "help.solid.cylinder": "Maakt een cilinder uit een straal of diameter en een hoogte, staand op een punt langs een as. Gebruik dit voor assen, pennen, nokken en ronde stukken ruw materiaal.", "help.solid.draft": "Kantelt de geselecteerde vlakken met de ingestelde hoek, gemeten vanaf een neutraal vlak. Gebruik dit bij gegoten of gevormde onderdelen zodat ze uit de mal loskomen.", + "help.solid.emboss": "Verheft schetsprofielen, zoals tekst of een logo, uit een vlak, of verzinkt ze erin. Gebruik dit voor belettering, labels en markeringen op platte of ronde vlakken.", "help.solid.extrude": "Duwt een schetsprofiel over een afstand de 3D in en voegt materiaal toe of verwijdert het. Gebruik dit om een vlakke contour om te zetten in een blok, een nok of een uitsparing.", "help.solid.fillet": "Rondt de geselecteerde randen af met een vloeiende cirkelvormige overgang met de ingestelde straal. Gebruik dit om scherpe randen te verzachten voor sterkte, comfort of uiterlijk.", "help.solid.hole": "Boort een rond gat met de ingestelde diameter op een gekozen punt van een vlak. Gebruik dit voor doorvoergaten voor schroeven, pennen en andere eenvoudige ronde openingen.", @@ -1425,20 +1637,60 @@ "help.solid.move": "Verplaatst of draait de geselecteerde lichamen, of een kopie ervan, over de ingestelde afstand en hoek. Gebruik dit om een lichaam een nieuwe positie te geven of om een tweede lichaam op een andere plek te maken.", "help.solid.offset_face": "Duwt de geselecteerde vlakken naar binnen of buiten over de ingestelde afstand, waarbij de omliggende vlakken meegaan. Gebruik dit om een wand dikker, een uitsparing dieper of een nok hoger te maken zonder de schets te bewerken.", "help.solid.pattern.circular": "Herhaalt de geselecteerde objecten rond een as, met het ingestelde aantal en de ingestelde totaalhoek. Gebruik dit voor boutcirkels, spaken, koelribben en tanden.", + "help.solid.pattern.path": "Kopieert kenmerken of lichamen langs een schetspad, op aantal en tussenafstand. Gebruik dit voor gaten langs een gebogen rand of onderdelen langs een rail.", "help.solid.pattern.rectangular": "Herhaalt de geselecteerde objecten in één of twee richtingen met het ingestelde aantal en de ingestelde afstand. Gebruik dit voor rijen en rasters van gaten, ribben, pennen of voeten.", + "help.solid.pipe": "Maakt een ronde buis langs een pad van schetscurven, massief of hol met een wanddikte. Gebruik dit voor buizen, draden, kabels en rails.", "help.solid.press_pull": "Voegt materiaal toe of verwijdert het terwijl een vlak wordt gesleept, of rondt randen af terwijl ze worden gesleept. Gebruik dit voor snelle wijzigingen rechtstreeks op het model.", "help.solid.remove": "Haalt de geselecteerde lichamen vanaf dit punt in de tijdlijn uit het ontwerp; eerdere stappen behouden ze. Gebruik dit om een hulplichaam te verwijderen, zoals een snijgereedschap dat niet meer nodig is.", "help.solid.replace_face": "Verplaatst de geselecteerde platte vlakken naar een werkvlak of een evenwijdig vlak, waarbij het lichaam groeit of krimpt om aan te sluiten. Gebruik dit om een vlak gelijk te trekken met een ander onderdeel of een constructievlak.", "help.solid.revolve": "Draait een schetsprofiel rond een as tot een rond solid. Gebruik dit voor assen, knoppen, kommen en elk onderdeel dat draaisymmetrisch is rond een hartlijn.", + "help.solid.rib": "Voegt een dunne wand toe langs een open schetscurve, uitgevuld tot de eerstvolgende vlakken van het lichaam. Gebruik dit om wanden en hoeken te verstijven, bijvoorbeeld in spuitgegoten of geprinte onderdelen.", "help.solid.scale": "Maakt de geselecteerde lichamen groter of kleiner met een factor, in elke richting gelijk of per as een eigen factor. Gebruik dit om een geïmporteerde vorm te schalen of krimp te compenseren.", "help.solid.shell": "Holt een solid uit, waarbij wanden van de ingestelde dikte overblijven, en kan de gekozen vlakken openen. Gebruik dit voor dozen, deksels en andere dunwandige onderdelen.", + "help.solid.sphere": "Maakt een bol uit een straal of diameter rond een middelpunt. Gebruik dit voor ballen, knoppen en afgeronde uiteinden.", "help.solid.split_body": "Snijdt een lichaam met een vlak, het vlak van een ander lichaam of een schetslijn in afzonderlijke lichamen. Gebruik dit voor onderdelen die afzonderlijk worden geprint, bewerkt of gekleurd.", "help.solid.split_face": "Verdeelt de geselecteerde vlakken langs een vlak, het vlak van een ander lichaam of schetscurven, zonder het lichaam te snijden. Gebruik dit om een deel van een vlak een eigen helling, kleur of offset te geven.", "help.solid.sweep": "Sleept een schetsprofiel langs een pad tot een solid. Gebruik dit voor buizen, rails, handgrepen en elk onderdeel met een constante doorsnede dat een curve volgt.", + "help.solid.thread": "Voegt een ISO-metrische schroefdraad toe aan een cilindrisch vlak, als cosmetische schroefdraad of in het lichaam gesneden. Gebruik dit voor bouten, tapgaten en geprinte schroefonderdelen.", + "help.solid.torus": "Maakt een ring uit een grote straal rond zijn as en een kleine straal voor zijn ronde doorsnede. Gebruik dit voor O-ringen, handvatten en ronde kralen.", + "help.solid.web": "Voegt een dunne wand toe langs elke open schetscurve, elk uitgevuld tot de eerstvolgende vlakken van het lichaam. Gebruik dit voor een raster van ondersteunende wanden in behuizingen en bakken.", + "help.surface.blend": "Maakt een oppervlak dat de randen van twee lichamen overbrugt, tangentieel of krommingscontinu aan beide. Gebruik dit om twee oppervlakken vloeiend over een opening heen te verbinden.", + "help.surface.boundary": "Maakt een oppervlak dat een lus van twee tot vier schetscurven of een netwerk van curven vult. Gebruik dit voor vrije vormpanelen die door hun randen zijn bepaald.", + "help.surface.extend": "Verlengt een oppervlak voorbij zijn randen, met een afstand of tot een vlak. Gebruik dit zodat oppervlakken overlappen voordat je ze trimt of naait.", + "help.surface.extrude": "Duwt schetscurven of profielen over een afstand uit tot een oppervlak, alleen wanden, zonder afsluitvlakken. Gebruik dit voor dunne wanden en voor snij- of splitsgereedschap.", + "help.surface.fair": "Egaliseert de binnenkant van vrije vormvlakken terwijl hun randen op hun plaats blijven. Gebruik dit om bobbels en golvende reflecties in een oppervlak glad te strijken.", + "help.surface.loft": "Maakt een oppervlak dat in volgorde door twee of meer doorsneden gaat, zonder afsluitvlakken. Gebruik dit voor schalen en overgangen tussen verschillende vormen.", + "help.surface.match": "Bouwt de rand van een oppervlak opnieuw op zodat hij aansluit op de rand van een ander lichaam, met overeenkomende tangent of kromming. Gebruik dit om een naad tussen twee oppervlakken vloeiend te sluiten.", + "help.surface.offset": "Maakt een kopie van vlakken op een afstand langs hun normalen, als nieuw oppervlaklichaam. Gebruik dit voor huiden, spelingen of de andere kant van een wand.", + "help.surface.patch": "Vult een schetsgebied of een gesloten lus van randen met een vlak, plat of vloeiend gebogen. Gebruik dit om gaten in een oppervlaklichaam te dichten.", + "help.surface.reverse_normal": "Keert vlakken van een oppervlaklichaam om, zodat hun buitenkant de andere kant op wijst. Gebruik dit vóór het verdikken of naaien wanneer de vlakken naar binnen wijzen.", + "help.surface.revolve": "Draait schetscurven of profielen om een as tot een oppervlak, zonder afsluitvlakken. Gebruik dit voor ronde schalen zoals lampenkappen, kommen en mondstukken.", + "help.surface.ruled": "Maakt een strook van rechte lijnen vanuit een keten van randen, langs de normaal of tangent van het vlak of onder een hoek. Gebruik dit voor aanzetwanden, uitlopen en scheidingsvlakken.", + "help.surface.stitch": "Naait oppervlaklichamen aan elkaar langs hun gedeelde randen, tot een massief lichaam wanneer ze sluiten. Gebruik dit om een reeks oppervlakken tot één lichaam te maken.", + "help.surface.sweep": "Beweegt schetscurven of profielen langs een pad tot een oppervlak, zonder afsluitvlakken. Gebruik dit voor goten, lijsten en andere dunne vormen die een pad volgen.", + "help.surface.thicken": "Geeft een oppervlaklichaam een dikte, waardoor het een massief lichaam wordt. Gebruik dit om printbare of bewerkbare onderdelen uit oppervlakken te maken.", + "help.surface.trim": "Snijdt een oppervlak met een vlak, een ander oppervlak of schetscurven en behoudt de zijde die je kiest. Gebruik dit om oppervlakken op vorm te snijden voordat je ze naait.", + "help.surface.unstitch": "Scheidt vlakken van een lichaam in eigen oppervlaklichamen; een massief lichaam valt uiteen in oppervlakken. Gebruik dit om afzonderlijke vlakken van een lichaam te herwerken.", + "help.surface.untrim": "Verwijdert gaten uit een vlak, of herstelt de buitengrens ervan tot de natuurlijke grenzen van het oppervlak. Gebruik dit om na een trim het volledige oppervlak terug te krijgen.", "help.third_party": "Licenties van derden", "help.timeline.compute_all": "Bouwt elk kenmerk in de tijdlijn opnieuw op vanaf het begin. Gebruik dit om het model te vernieuwen en te zien welke kenmerken mislukken.", "help.timeline.delete": "Verwijdert de geselecteerde kenmerken uit de tijdlijn; als een schets wordt verwijderd, gaan ook de kenmerken eruit die erop zijn gebouwd. Gebruik dit om een stap te verwijderen die niet meer gewenst is.", + "help.timeline.dependents": "Toont de kenmerken die met een kenmerk zouden worden verwijderd, en die zonder dat kenmerk zouden mislukken. Gebruik dit voordat je een kenmerk verwijdert of wijzigt.", "help.timeline.play": "Speel de resterende kenmerken één voor één opnieuw af, met een pauze van een halve seconde na elke herberekening. Voer deze opdracht opnieuw uit of druk op Escape om te stoppen; Naar het einde springt direct.", + "help.view.apply": "Draait de weergave naar een standaardrichting zoals voor, boven of iso, of naar een opgeslagen benoemde weergave. Gebruik dit om het model vanuit een vaste hoek te bekijken.", + "help.view.delete": "Verwijdert een opgeslagen benoemde weergave. Het model wordt niet gewijzigd.", + "help.view.fit": "Zoomt en pant zodat het hele model, of de selectie, de weergave vult. Gebruik dit wanneer het model uit beeld is geraakt.", + "help.view.grid": "Toont of verbergt het raster in de viewport. Gebruik dit als hulp voor maat en oriëntatie tijdens het modelleren.", + "help.view.ground_shadow": "Toont of verbergt de schaduw van het model op de grond. Gebruik dit om hoogte en positie gemakkelijker te beoordelen.", + "help.view.home": "Draait de weergave terug naar de beginweergave en past het model in beeld. Gebruik dit om snel terug te keren naar een bekende weergave.", + "help.view.list": "Toont de benoemde weergaven die in het ontwerp zijn opgeslagen. Gebruik dit om een eerder opgeslagen weergave te vinden.", + "help.view.navigate": "Draait, pant of zoomt de weergave in stappen, zoals een kwartslag naar links. Gebruik dit om de camera nauwkeurig te bewegen in plaats van te slepen.", + "help.view.navigation_bar": "Toont of verbergt de navigatiebalk onderaan de viewport. Verberg hem voor meer ruimte, toon hem om de weergavegereedschappen met de muis te bereiken.", + "help.view.rename": "Geeft een opgeslagen benoemde weergave een nieuwe naam. De weergave zelf blijft hetzelfde.", + "help.view.save": "Slaat de huidige camerapositie op als benoemde weergave in het ontwerp. Gebruik dit om later naar dezelfde hoek terug te keren, bijvoorbeeld voor schermafbeeldingen.", + "help.view.spin": "Laat de weergave continu om het model draaien tot de volgende klik of toets. Gebruik dit om een model van alle kanten te presenteren.", + "help.view.tool": "Vergrendelt een cameragereedschap, roteren, pannen of zoomen, zodat slepen met de linkermuisknop de weergave beweegt tot je het uitschakelt. Gebruik dit op een trackpad of zonder middelste muisknop.", + "help.view.visual_style": "Wijzigt hoe het model wordt getekend: gearceerd met of zonder randen, draadmodel, of met verborgen randen. Gebruik draadmodel om naar binnen te kijken en gearceerd voor een nette weergave.", "history.sketch_under_constrained": "Onderbeperkt ({count} vrijheidsgraden)", "history.sketch_under_constrained_one": "Onderbeperkt ({count} vrijheidsgraad)", "hole_presets.confirm_delete": "Verwijderen bevestigen", @@ -1615,6 +1867,10 @@ "material.selection.mixed": "Verschillende materialen", "measure.at": "Bij", "measure.item": "Item {number}", + "measure.quick_copied": "Meting gekopieerd", + "measure.quick_copy": "Klik om de meting te kopiëren", + "measure.quick_edges": "{count} randen", + "measure.quick_faces": "{count} vlakken", "messages.ago_h": "{n} u geleden", "messages.ago_min": "{n} min geleden", "messages.ago_s": "{n} s geleden", @@ -1767,6 +2023,11 @@ "not a move": "geen verplaatsing", "not a number": "geen getal", "open a sketch first": "open eerst een schets", + "parameters.apply_live": "Elke wijziging toepassen zodra je het veld verlaat", + "parameters.apply_live_tip": "Uit: wijzigingen wachten tot Toepassen of OK, daarna wordt het ontwerp één keer voor alle wijzigingen opnieuw opgebouwd. Annuleren of Escape verwerpt ze.", + "parameters.held": "{count} wijzigingen wachten op Toepassen", + "parameters.held_hint": "Bewerk expressies en eenheden. Wijzigingen wachten op Toepassen of OK.", + "parameters.held_one": "1 wijziging wacht op Toepassen", "parts.automatic_name": "automatisch", "parts.dimension.across_flats": "Sleutelwijdte", "parts.dimension.bore": "Boring", @@ -1957,6 +2218,8 @@ "prefs.origin_plane_names": "Namen van oorsprongsvlakken", "prefs.origin_plane_names_note": "Namen die worden getoond in de browser, dialoogvensters en lijsten. {top} is XY, {front} is XZ en {right} is YZ, met Z omhoog. Opdrachten, scripts en opgeslagen ontwerpen blijven XY, XZ en YZ gebruiken.", "prefs.overall_speed": "Totale snelheid", + "prefs.parameter_list_rows": "Rijen in de parameterlijst", + "prefs.parameter_list_rows_tip": "De pijl-omlaag in een leeg waardeveld toont de parameters, favorieten eerst, tot dit aantal. Minder houdt de lijst licht in een groot ontwerp.", "prefs.persistent_ribbon_pressed": "Actieve lintopdrachten markeren", "prefs.persistent_ribbon_pressed_help": "Lintopdrachten markeren zolang hun effect actief is. Zet dit uit om het vorige neutrale uiterlijk en de vorige opdrachthulp te behouden.", "prefs.perspective_fov": "Gezichtsveld", @@ -1986,6 +2249,8 @@ "prefs.quality_fastest": "snelste", "prefs.quality_slowest": "langzaamste", "prefs.quality_smoother": "gladder", + "prefs.quick_measure": "Snelle meting in de hoek van de weergave", + "prefs.quick_measure_note": "De grootte van de selectie rechtsonder in de weergave: de lengte van een rand, of Ø en R van een ronde rand, de oppervlakte van een vlak, de positie van een punt, de afstand en hoek van twee items, het totaal van meerdere randen of vlakken. Een klik kopieert hem.", "prefs.render_previous_geometry_budget": "Vorig geometriebudget voor het rendervoorbeeld gebruiken", "prefs.render_previous_geometry_budget_help": "De vorige vaste geometrielimiet herstellen (512 MiB op 64-bits desktops, elders 128 MiB) in plaats van het budget op basis van het apparaat.", "prefs.reports_saved": "{n} opgeslagen", @@ -2031,6 +2296,8 @@ "prefs.sketch_tab_always_note": "De Schets-tab blijft in het lint staan, direct na Model, ook als er geen schets open is. Zijn tekentools beginnen dan een schets op het geselecteerde vlak, of op het vlak dat je daarna kiest, met de tool daarin klaar voor gebruik.", "prefs.sketch_tracking_guides": "Uitlijnhulplijnen voor midden en middelpunt", "prefs.sketch_tracking_guides_note": "Wijs naar het midden van een cirkel of het middelpunt van een rechte rand en beweeg dan horizontaal of verticaal om een tijdelijke gestippelde hulplijn te tonen.", + "prefs.sketch_tracking_points": "Uitlijnhulplijnen vanaf eindpunten en hoeken tijdens het tekenen", + "prefs.sketch_tracking_points_note": "Wanneer een tekengereedschap op een punt wacht, toont het aanwijzen van het uiteinde van een lijn of boog, een hoek van een rechthoek of een los punt dezelfde tijdelijke stippellijn als bij een middelpunt of midden. Uit: alleen middelpunten en middens.", "prefs.sketching": "Schetsen", "prefs.snap_drawing_note": "Tijdens het tekenen of slepen van een schetspunt.", "prefs.source": "Bron", @@ -2058,6 +2325,16 @@ "prefs.theme_follow_off": "Uit", "prefs.theme_follow_stop": "Volgen stoppen", "prefs.theme_import_hint": "Klik om een themabestand te kiezen, sleep er een hierheen of druk op Ctrl+V (Cmd+V op een Mac) met een gekopieerd thema", + "prefs.theme_option_opacity": "Dekking", + "prefs.theme_option_opacity_help": "Pas de dekking van zwevende panelen aan. Een minimum voor leesbaarheid beschermt tekst boven de kleuren van de viewport.", + "prefs.theme_option_tab_colours": "Tabbladkleuren", + "prefs.theme_option_tab_colours_help": "Gebruik de paneel- en viewportkleuren van dit thema voor elk tabblad van het lint.", + "prefs.theme_option_tab_fade_ms": "Tabbladovergang", + "prefs.theme_option_tab_fade_ms_help": "Laat paneel- en viewportkleuren vervagen bij het wisselen van tabblad. Nul schakelt direct om.", + "prefs.theme_options_badge": "Opties", + "prefs.theme_options_more": "Meer · {count}", + "prefs.theme_options_reset": "Herstellen", + "prefs.theme_options_unavailable": "Niet beschikbaar op dit apparaat", "prefs.theme_paste": "Thema plakken", "prefs.theme_paste_none": "Het klembord bevat geen SolidCraft-thema", "prefs.timeline_half_hour": "−30 min", @@ -2079,6 +2356,8 @@ "prefs.under_cursor": "Onder de cursor", "prefs.units_precision": "Eenheden en precisie", "prefs.updates": "Updates", + "prefs.value_boxes_in_view": "Waarden naast hun grepen in de weergave tonen", + "prefs.value_boxes_in_view_tip": "De waarde van een opdracht (Afstand, Straal…) in een vak naast de greep, boven het model. Uit: je typt de waarde in het opdrachtvenster, dat na een keuze het toetsenbord overneemt, en niets bedekt het model.", "prefs.view": "Weergavegedrag", "prefs.view_confirm_corner": "OK en Annuleren in de hoek van de weergave", "prefs.view_confirm_corner_note": "Zolang een dialoogvenster of een schets open is, doen een vinkje en een kruisje rechtsboven in de weergave hetzelfde als OK en Annuleren van het dialoogvenster, of ze ronden de schets af of annuleren die. De weergavenavigator schuift daaronder naar beneden.", @@ -2088,6 +2367,12 @@ "prefs.viewport_background": "Achtergrond", "prefs.viewport_background_colour": "Kleur", "prefs.viewport_background_theme": "Verloop van het thema", + "prefs.viewport_section_status": "Doorsnedestatus van de viewport tonen", + "prefs.viewport_section_status_help": "Toon de doorsnede-instelling in de viewport. Escape en de actieve lintknop schakelen hem uit. Schakel uit om de vorige doorsnedebediening te behouden.", + "prefs.viewport_status_details": "Live details van de viewport", + "prefs.viewport_status_details_help": "Bewerk actieve analyse- en doorsnede-instellingen in standaarddetailkaarten. Schakel uit om de alleen-lezen analysetoetsen en het dialoogvenster Doorsnede te herstellen.", + "prefs.viewport_status_summary": "Statusitems van de viewport samenvouwen", + "prefs.viewport_status_summary_help": "Vouw drie of meer statusitems van de viewport samen tot één overzichtsbadge. Schakel uit om elk item in de volledige rij te houden.", "prefs.wheel": "Wiel", "prefs.wheel_trackpad": "Wiel en trackpad", "prefs.workspace": "Werkruimte", @@ -2966,7 +3251,9 @@ "render.output_ready": "Gereed om te renderen", "render.output_run_check": "Controle uitvoeren", "render.output_size_limit": "{method}: tot {dimension} px per zijde en {pixels} pixels. Grootste passende formaat: {width} × {height}.", + "render.output_size_method_profile": "Kies voor het renderen van {width} × {height} de methode {method} met het CPU-resourceprofiel {profile}, of een kleiner formaat.", "render.output_size_ok": "Grootte OK · {width} × {height} px", + "render.output_size_profile": "Kies voor het renderen van {width} × {height} het CPU-resourceprofiel {profile}, of een kleiner formaat.", "render.output_swap_orientation": "Breedte en hoogte omwisselen", "render.output_tools_inspector": "Bewerk grootte en bestandsindeling in de inspecteur Uitvoer.", "render.output_view_image": "Afbeelding bekijken", @@ -3179,6 +3466,7 @@ "render.size_custom": "Aangepast…", "render.size_full_hd": "Full HD", "render.size_hd": "HD", + "render.size_needs_profile": "{size} · vereist {profile}", "render.size_presets": "Groottevoorinstellingen", "render.size_presets_pending": "Komt met de Uitvoer-editor", "render.size_preview": "Voorbeeld", @@ -3466,6 +3754,24 @@ "sheet.flange_type_contour": "Contour", "sheet.flange_type_edge": "Rand", "sheet.no_body": "er is geen plaatwerklichaam: begin er een met Flens > Basis, of zet een plaat om in plaatwerk met Converteren naar plaatwerk (kies het grote platte vlak ervan)", + "sheet.personal_rules.confirm_delete": "Verwijderen bevestigen", + "sheet.personal_rules.delete": "Verwijderen", + "sheet.personal_rules.delete_prompt": "Deze persoonlijke regel verwijderen? Regels die al in ontwerpen staan, blijven ongewijzigd.", + "sheet.personal_rules.full": "De persoonlijke bibliotheek bevat al 64 regels.", + "sheet.personal_rules.invalid_name": "Voer een korte naam in zonder regeleinden of stuurtekens.", + "sheet.personal_rules.invalid_values": "Vul de waarden van de regel aan: elke waarde moet in dit ontwerp uitwerkbaar zijn, met een positieve dikte en een K-factor van 0 tot 1.", + "sheet.personal_rules.missing": "De geselecteerde persoonlijke regel is niet meer beschikbaar.", + "sheet.personal_rules.name": "Regelnaam", + "sheet.personal_rules.name_exists": "Er bestaat al een persoonlijke regel met deze naam. Gebruik een andere naam.", + "sheet.personal_rules.name_hint": "Een unieke naam", + "sheet.personal_rules.overwrite": "Wijzigingen in geselecteerde persoonlijke regel opslaan", + "sheet.personal_rules.rename": "Hernoemen", + "sheet.personal_rules.rule": "Persoonlijke regel", + "sheet.personal_rules.save": "Als nieuw opslaan", + "sheet.personal_rules.scope": "Opgeslagen in je profiel voor alle ontwerpen.", + "sheet.personal_rules.select": "Selecteer een regel", + "sheet.personal_rules.title": "Persoonlijke regels", + "sheet.personal_rules.unsupported": "Deze regel bevat waarden die deze versie niet kan gebruiken.", "shortcut.json_error": "parameters moeten JSON zijn: {error}", "shortcut.no_command": "geen opdracht `{id}`; de tekst vóór de JSON moet een opdracht-id zijn", "shortcut.pin": "Bovenaan het S-vak vastpinnen", @@ -3502,6 +3808,7 @@ "sketch_text.align_right": "Rechts", "sketch_text.bold": "Vet", "sketch_text.builtin_font": "{family} (ingebouwd)", + "sketch_text.formula": "Begin met = om de woorden uit parameters op te bouwen: ='W' + number(Width)", "sketch_text.italic": "Cursief", "sketch_text.loading_fonts": "Geïnstalleerde lettertypen zoeken…", "sketch_text.on_path": "De tekst volgt zijn pad: de curve plaatst hem", @@ -3642,6 +3949,8 @@ "ui.3d_mouse_section": "3D-muis", "ui.3d_mouse_show_in_view": "De 3D-muis tonen in de 3D-weergave", "ui.3d_mouse_show_in_view_tip": "Een afbeelding van je 3D-muis in de linkerbenedenhoek, die meebeweegt als jij de muis beweegt.", + "ui.3d_mouse_sketch_orbit": "Schetsen roteren met de 3D-muis", + "ui.3d_mouse_sketch_orbit_tip": "De 3D-muis roteert de weergave ook terwijl een schets open is en recht van voren wordt bekeken, zoals de muis dat kan. Uit: hij pant en zoomt de schets totdat je de weergave kantelt.", "ui.3d_mouse_skip": "Mijn apparaat kan dit niet", "ui.3d_mouse_skip_tip": "Slaat deze beweging in beide richtingen over, en de kalibratie laat haar weg.", "ui.3d_mouse_speed": "Snelheid van 3D-muis", @@ -3710,6 +4019,7 @@ "ui.agent_chat_auth_terminal": "Voer dit uit in een terminal en druk daarna opnieuw op Verbinden:", "ui.agent_chat_auth_title": "Aanmelden bij de agent", "ui.agent_chat_cancel": "Annuleren", + "ui.agent_chat_cannot_start": "Geïnstalleerd, maar start niet: {error}", "ui.agent_chat_changes": "Wijzigingen: {n}, één stap ongedaan maken", "ui.agent_chat_changes_n": "{n} wijzigingen", "ui.agent_chat_changes_of": "{n}/{limit} wijzigingen", @@ -3802,9 +4112,12 @@ "ui.agent_chat_queued_stopped": "In wachtrij (gestopt, niet verzonden)", "ui.agent_chat_refresh": "Vernieuwen", "ui.agent_chat_refused": "Niet toegestaan voor de agent: {reason}", + "ui.agent_chat_reinstall": "{agent} is geïnstalleerd ({program}), maar is niet gestart toen SolidCraft het controleerde: {error}\nDe installatie is mogelijk onvolledig, of gemaakt voor een ander type computer. Installeer opnieuw met `{install}`, controleer in een terminal of de versie wordt afgedrukt en druk dan op Vernieuwen.", "ui.agent_chat_remove": "Uit de wachtrij verwijderen", "ui.agent_chat_remove_file": "Van dit bericht verwijderen", "ui.agent_chat_remove_imported": "Van dit bericht verwijderen; het onderdeel blijft in het ontwerp (Ongedaan maken verwijdert het)", + "ui.agent_chat_reply_cut_off": "Het antwoord bereikte zijn lengtelimiet voordat het klaar was en is mogelijk onvolledig. Vraag de agent om door te gaan, of om een korter antwoord.", + "ui.agent_chat_reply_declined": "Het model weigerde dit bericht te beantwoorden.", "ui.agent_chat_retry": "Opnieuw proberen", "ui.agent_chat_right": "Rechts", "ui.agent_chat_save": "Opslaan", @@ -3863,12 +4176,17 @@ "ui.ai_copy_suffix": "kopie", "ui.ai_custom_url": "Aangepaste URL", "ui.ai_default": "Standaard", + "ui.ai_edit": "Bewerken", + "ui.ai_edit_agent_title": "Agent bewerken", + "ui.ai_edit_gone": "Deze agent is verwijderd, dus er valt niets op te slaan.", + "ui.ai_edit_tip": "Wijzig de naam, opdracht of het eindpunt van deze agent. Een opgeslagen sleutel blijft verborgen.", "ui.ai_http_busy": "het eindpunt is bezet of boven zijn limiet; probeer het later opnieuw", "ui.ai_http_key": "het eindpunt weigerde de sleutel: controleer deze in Voorkeuren > AI", "ui.ai_http_not_found": "geen eindpunt of model gevonden: controleer de basis-URL en de modelnaam", "ui.ai_http_too_large": "het gesprek is te groot voor dit endpoint; begin een nieuwe chat", "ui.ai_key": "API-sleutel", "ui.ai_key_hint": "optioneel; bewaard in de sleutelhanger van het systeem", + "ui.ai_key_keep_hint": "laat leeg om de opgeslagen sleutel te behouden", "ui.ai_key_needs_https": "Een sleutel wordt alleen via HTTPS of naar deze computer verzonden.", "ui.ai_key_note": "Bewaard in de sleutelhanger van het systeem, nooit in de instellingen van SolidCraft. Laat leeg voor een lokale server.", "ui.ai_keychain_error": "De sleutelhanger van het systeem weigerde: {error}", @@ -3900,6 +4218,7 @@ "ui.ai_request_timeout": "Time-out voor aanvragen", "ui.ai_request_timeout_hint": "Hoelang één modelaanvraag aan dit eindpunt mag duren, van 30 s tot een uur. Verhoog dit voor een traag lokaal model.", "ui.ai_reset": "Herstellen", + "ui.ai_save": "Opslaan", "ui.ai_settings": "AI-instellingen…", "ui.ai_show_features": "AI-functies tonen", "ui.ai_show_features_note": "De agentchat: een paneel waarin een codeeragent die u al gebruikt, of een modeleindpunt naar keuze, aan het geopende ontwerp werkt. Uit: geen AI-knop, sneltoets, menu-item of paneel, en er wordt niets gezocht of gestart.", @@ -4073,6 +4392,40 @@ "ui.design_properties.project": "Project", "ui.design_properties.revision": "Revisie", "ui.design_saved": "Opgeslagen in {path}", + "ui.details_access_caption": "Vlakken tonen of een gereedschap ze vanuit de gekozen richting kan bereiken.", + "ui.details_angle": "Hoek", + "ui.details_continuity_caption": "Bewerk de geselecteerde naad. Andere naden behouden hun eigen instellingen.", + "ui.details_continuity_stale": "Eerdere geometrie: voer de analyse opnieuw uit om deze naad te bewerken of in beeld te brengen.", + "ui.details_copy_list": "Lijst kopiëren", + "ui.details_curvature": "Krommingstolerantie", + "ui.details_curvature_caption": "Vlakken worden gekleurd op hun lokale krommingsstraal.", + "ui.details_direction": "Gereedschapsrichting", + "ui.details_draft_caption": "Vlakken worden gekleurd op hun hoek ten opzichte van de trekrichting.", + "ui.details_edit_seam": "Naad bewerken", + "ui.details_fixture": "Voorbeeldcomponent — alleen voorbeeldgegevens", + "ui.details_fixture_caption": "Voorbeeldcomponent met illustratieve waarden; dit is geen modelresultaat.", + "ui.details_fixture_problem": "Voorbeeldbevinding met een probleem", + "ui.details_fixture_readout": "Voorbeeld van een gemeten waarde", + "ui.details_flip": "Omdraaien", + "ui.details_gap": "Opening", + "ui.details_hatch": "Snijvlakken arceren", + "ui.details_jump": "Krommingssprong", + "ui.details_key": "Legenda", + "ui.details_offset": "Offset", + "ui.details_plane": "Vlak", + "ui.details_pull": "Trekrichting", + "ui.details_radius": "Straal", + "ui.details_readout": "Uitlezing", + "ui.details_samples": "Samples", + "ui.details_seam": "{body} · Rand {edge}", + "ui.details_settings": "Instellingen", + "ui.details_sharp_gap": "Scherp of opening", + "ui.details_show_all": "Alle {count} tonen", + "ui.details_smooth": "Glad", + "ui.details_stripes": "Strepen", + "ui.details_target": "Geometrie in beeld brengen", + "ui.details_worst_seams": "Slechtste naden", + "ui.details_zebra_caption": "Weerspiegelde strepen tonen hoe vloeiend vlakken op elkaar aansluiten.", "ui.diameter": "Diameter", "ui.dimension": "Maat", "ui.direct_interaction": "Directe interactie", @@ -4133,6 +4486,7 @@ "ui.equation_y_of_x": "y(x)", "ui.equivalent_radius": "Equivalente straal", "ui.expand": "Uitvouwen", + "ui.explode_needs_two_components": "Explosieweergave trekt componenten uit elkaar, dus het ontwerp heeft er twee of meer nodig (Nieuw component, Ontwerp invoegen of Componenten maken van lichamen)", "ui.exportDxf": "DXF exporteren…", "ui.export_ellipsis_keymap": "Exporteren…", "ui.export_keymap_tip": "Sneltoetsen, favorieten van het S-vak en de werkbalk, als bestand", @@ -4421,12 +4775,17 @@ "ui.plugins_skipped": "{count} map(pen) kon(den) niet worden gelezen.", "ui.plugins_version": "{version}", "ui.point": "Punt", + "ui.point_cloud_add_filter": "Filter toevoegen…", "ui.point_cloud_auto_range": "Automatisch bereik", "ui.point_cloud_color_by": "Kleuren op basis van", "ui.point_cloud_file_colors": "Bestandskleuren", + "ui.point_cloud_filter_by": "Filteren op", "ui.point_cloud_height_high": "Hoogste", "ui.point_cloud_height_low": "Laagste", + "ui.point_cloud_legend_filter": "Getoond en verborgen in de legenda hierboven", "ui.point_cloud_no_file_colors": "Het bestand bevat geen kleuren", + "ui.point_cloud_pause": "Pauzeren", + "ui.point_cloud_play": "Afspelen", "ui.point_cloud_single_color": "Eén kleur", "ui.point_cloud_size": "Puntgrootte", "ui.point_cloud_units": "Bestandseenheden", @@ -4455,6 +4814,7 @@ "ui.properties.inertia_y": "Iyx, Iyy, Iyz", "ui.properties.inertia_z": "Izx, Izy, Izz", "ui.properties.measuring": "Meten…", + "ui.properties.measuring_bodies": "Meten… {done} van {total} lichamen", "ui.properties.measuring_failed": "Het meten is gestopt voordat het klaar was.", "ui.properties.principal_axis": "Hoofdas {number}", "ui.properties.principal_moments": "Hoofdtraagheidsmomenten", @@ -4522,6 +4882,7 @@ "ui.selectOther": "Overige selecteren…", "ui.selectTangentChain": "Tangentiële keten selecteren", "ui.select_all": "Alles selecteren", + "ui.select_components_by_face": "Componenten selecteren (een vlak kiest zijn component)", "ui.selection": "Selectie", "ui.selection_modifiers": "Selectiemodificatoren", "ui.selection_shift_add_only": "Shift+klik voegt toe zonder te verwijderen", @@ -4563,6 +4924,11 @@ "ui.sketch_intersect_selection_filter": "Selectiefilter", "ui.sketch_modifiers": "Schetsmodificatoren", "ui.sketch_project_geometry": "Geometrie", + "ui.sketch_report.all_constrained": "Alles is volledig beperkt.", + "ui.sketch_report.dof": "Vrijheidsgraden", + "ui.sketch_report.loose": "Niet volledig beperkt", + "ui.sketch_report.status": "Status", + "ui.sketch_report.title": "Schetsrapport", "ui.sketch_scale": "Schetsschaal", "ui.sketches": "Schetsen", "ui.slice": "Slicen", @@ -4672,7 +5038,14 @@ "ui.viewport_status_key": "Legenda tonen voor {name}", "ui.viewport_status_minimum_radius": "Minimale straal", "ui.viewport_status_pairs": "{count} paren", + "ui.viewport_status_section_hatch": "{setting} · arcering", + "ui.viewport_status_section_plane": "Huidig vlak", + "ui.viewport_status_section_tilted": "gekanteld · {offset}", "ui.viewport_status_stripes": "{count} strepen", + "ui.viewport_status_summary_clear": "Alles uitschakelen", + "ui.viewport_status_summary_heading": "In deze weergave", + "ui.viewport_status_summary_label": "{name} en {count} andere items", + "ui.viewport_status_summary_more": "+{count}", "ui.viewport_theme_follow": "Thema volgen", "ui.visual_shaded_hidden_edges": "Gearceerd met verborgen randen", "ui.visual_shaded_with_edges": "Gearceerd met randen", diff --git a/assets/locales/pl.json b/assets/locales/pl.json index 95a3f31b93..20294dfe80 100644 --- a/assets/locales/pl.json +++ b/assets/locales/pl.json @@ -817,6 +817,7 @@ "cmd.appearance_pick_up": "Pobierz wygląd", "cmd.appearance_replace": "Zastąp wygląd", "cmd.assembly_bom": "Zestawienie materiałowe", + "cmd.assembly_mass_properties": "Właściwości masowe", "cmd.body.print_face": "Powierzchnia druku", "cmd.body_rename": "Zmień nazwę bryły", "cmd.browser_collapse_all": "Zwiń wszystko", diff --git a/assets/locales/pt-BR.json b/assets/locales/pt-BR.json index 51b4c84fa1..19b0560447 100644 --- a/assets/locales/pt-BR.json +++ b/assets/locales/pt-BR.json @@ -606,6 +606,7 @@ "cmd.appearance_library": "Biblioteca de aparências", "cmd.appearance_list": "Listar aparências", "cmd.assembly_bom": "Lista de materiais", + "cmd.assembly_mass_properties": "Propriedades de massa", "cmd.body.print_face": "Face de impressão", "cmd.body_rename": "Renomear corpo", "cmd.browser_group": "Novo grupo", diff --git a/assets/locales/ro.json b/assets/locales/ro.json index 3ba059087f..48b5b7910b 100644 --- a/assets/locales/ro.json +++ b/assets/locales/ro.json @@ -60,6 +60,7 @@ "BLEND SURFACE": "SUPRAFAȚĂ DE RACORDARE", "BOUNDARY SURFACE": "SUPRAFAȚĂ LIMITĂ", "BROWSER": "NAVIGATOR", + "Back to the original order": "Înapoi la ordinea inițială", "Ball": "Sferică", "Bar": "Bară", "Base Center": "Centrul bazei", @@ -595,6 +596,8 @@ "Snap to face and edge centres": "Agățare la centrele fețelor și muchiilor", "SolidCraft cannot submit reports directly. Submit on GitHub; no token is needed in the app.": "SolidCraft nu poate trimite rapoarte direct. Trimiteți raportul pe GitHub; nu este necesar niciun token în aplicație.", "SolidCraft closed before these designs were saved. Their last autosave can be opened.": "SolidCraft s-a închis înainte ca aceste proiecte să fie salvate. Ultima lor salvare automată poate fi deschisă.", + "Sort A to Z": "Sortează de la A la Z", + "Sort Z to A": "Sortează de la Z la A", "Spacing 2 must be a non-zero number": "Spațierea 2 trebuie să fie un număr nenul", "Spacing must be a non-zero number": "Spațierea trebuie să fie un număr nenul", "Specialise": "Specializează", @@ -819,6 +822,7 @@ "cmd.appearance_pick_up": "Preia aspectul", "cmd.appearance_replace": "Înlocuiește aspectul", "cmd.assembly_bom": "Listă de materiale", + "cmd.assembly_mass_properties": "Proprietăți de masă", "cmd.body.print_face": "Față de imprimare", "cmd.body_rename": "Redenumește corpul", "cmd.browser_collapse_all": "Restrânge tot", @@ -1244,6 +1248,7 @@ "cmd.view.render_browser": "Navigator de randare", "cmd.view.render_dock": "Mută panoul de randare", "cmd.view.render_focus": "Vizualizare de randare concentrată", + "cmd.view.status_details": "Detalii de stare ale ferestrei de vizualizare", "cmd.view.status_items": "Elemente de stare ale ferestrei de vizualizare", "cmd.view_ambient_occlusion": "Ocluzie ambientală", "cmd.view_angle_readout": "Unghiuri de vizualizare", @@ -1387,21 +1392,168 @@ "from the rule": "din regulă", "help.apache": "Licența Apache 2.0", "help.appearance.assign": "Dă corpurilor, fețelor sau componentelor selectate o culoare sau un aspect de material, fără a le modifica greutatea. Folosiți-l pentru a deosebi piesele sau pentru a previzualiza un finisaj.", + "help.assembly.bom": "Listează piesele proiectului sau ale unei componente, cu cantitățile lor, pe numere de piesă, și poate salva lista ca CSV sau JSON. Folosiți-o ca listă de piese pentru comandă și asamblare.", "help.attributions": "Atribuiri (ATTRIBUTION.md)", "help.build": "Versiunea {version} · commit {commit} · construit {date}", + "help.canvas.decal": "Plasează o imagine pe o față plană a modelului, ca un autocolant. Folosiți-l pentru a arăta o etichetă, un logo sau un model imprimat pe o piesă.", + "help.canvas.edit": "Modifică o pânză: poziția, dimensiunea, unghiul, opacitatea, oglindirea, vizibilitatea sau numele. Folosiți-o pentru a alinia o imagine de referință cu schița trasată peste ea.", + "help.canvas.insert": "Plasează o imagine PNG sau JPEG pe un plan sau pe o față, ca referință transparentă. Folosiți-o pentru a copia o fotografie sau un desen într-o schiță, cu imaginea scalată la o dimensiune cunoscută.", + "help.component.break_link": "Desprinde o componentă legată de fișierul ei de proiect, păstrându-i corpurile așa cum sunt. Folosiți-o când piesa nu mai trebuie să urmeze modificările fișierului original.", + "help.component.capture_position": "Păstrează pozițiile pieselor mutate manual, astfel încât acestea să devină noile lor poziții. Folosiți-o după ce ați tras în loc piese pe care nu le ține nicio îmbinare.", + "help.component.create": "Adaugă în proiect o componentă nouă, goală, în componenta activă, dacă nu alegeți un alt părinte. Folosiți-o pentru a da unei piese noi a unui ansamblu propriul loc pentru corpuri și schițe.", "help.component.finish_edit": "Salvează piesa pe care o editați pe loc, revine la ansamblul ei și actualizează ansamblul pe baza piesei. Folosiți-o când modificările piesei legate sunt gata.", + "help.component.from_bodies": "Transformă fiecare corp selectat într-o componentă proprie, cu numele corpului. Folosiți-o când un proiect construit din corpuri separate trebuie să devină un ansamblu ale cărui piese pot fi îmbinate și mutate.", + "help.component.insert_design": "Inserează un alt proiect salvat ca o componentă legată de fișierul său, cu corpurile solide plasate ca acolo. Folosiți-o pentru a reutiliza o piesă într-un ansamblu și a prelua modificările ulterioare cu Actualizează legăturile.", + "help.component.properties": "Arată volumul, masa, centrul de masă și momentele de inerție ale unei componente, pe baza materialelor corpurilor sale. Folosiți-o pentru a verifica greutatea și echilibrul unui ansamblu.", + "help.component.revert_position": "Readuce piesele mutate manual unde erau, renunțând la mutările necapturate. Folosiți-o pentru a anula mutările de probă dintr-un ansamblu.", + "help.component.update_links": "Citește din nou fișierele de proiect legate și înlocuiește corpurile care s-au modificat. Folosiți-o după editarea unei piese folosite de acest ansamblu.", + "help.config.configure": "Adaugă parametri, operații, materiale sau aspecte ca coloane ale tabelului de configurații. Folosiți-o pentru a crea variante ale unui proiect, de exemplu dimensiuni ale unei piese.", + "help.config.preview": "Verifică dacă un rând al tabelului de configurații se reconstruiește, fără a modifica proiectul sau istoricul lui. Folosiți-o înainte de a trece la o variantă de care nu sunteți sigur.", + "help.config.rules": "Arată regulile tabelului de configurații, rândurile lui și care rând este activ. Folosiți-o pentru a vedea cum sunt definite variantele unui proiect.", + "help.config.table": "Arată tabelul de configurații: un rând pentru fiecare variantă, o coloană pentru fiecare parametru sau operație și dacă proiectul se potrivește încă cu rândul activ. Folosiți-o pentru a revizui variantele unui proiect.", "help.connect_note": "Reporniți fiecare asistent pentru a încărca serverul SolidCraft. Din fiecare fișier s-a făcut o copie de rezervă .bak înainte de modificare.", "help.connect_title": "Conectare asistenți AI", + "help.construct.axis.edge": "Creează o axă de construcție de-a lungul unei muchii drepte sau a unei linii de schiță. Folosiți-o ca axă pentru o revoluție, o matrice circulară sau o îmbinare.", + "help.construct.axis.normal_to_face": "Creează o axă de construcție printr-un punct, perpendiculară pe o față. Folosiți-o ca axă care iese drept dintr-o suprafață.", + "help.construct.axis.through_cylinder": "Creează o axă de construcție prin centrul unei fețe cilindrice, conice sau toroidale ori al unei muchii circulare. Folosiți-o pentru a roti sau a multiplica operații în jurul unei piese rotunde.", + "help.construct.axis.two_planes": "Creează o axă de construcție acolo unde se întâlnesc două plane sau fețe plane. Folosiți-o când axa de care aveți nevoie este linia în care se întâlnesc două fețe.", + "help.construct.axis.two_points": "Creează o axă de construcție prin două puncte, vârfuri sau puncte de schiță. Folosiți-o pentru o axă care trece între două poziții cunoscute.", + "help.construct.plane.along_path": "Creează un plan de construcție perpendicular pe o muchie sau o curbă, într-o poziție de-a lungul ei. Folosiți-l pentru a schița un profil de baleiere sau o secțiune prin traseu.", + "help.construct.plane.at_angle": "Creează un plan de construcție rotit cu un unghi față de un plan sau o față, în jurul unei axe sau muchii. Folosiți-l pentru schițe înclinate, de exemplu un suport în unghi.", + "help.construct.plane.midplane": "Creează un plan de construcție la jumătatea distanței dintre două plane sau fețe plane ori care le bisectează acolo unde se întâlnesc sub un unghi. Folosiți-l ca plan de simetrie pentru oglindire.", + "help.construct.plane.offset": "Creează un plan de construcție paralel cu un plan sau o față plană, la o distanță sau printr-un punct. Folosiți-l pentru a schița la o înălțime stabilită deasupra unei fețe.", + "help.construct.plane.perpendicular": "Creează un plan de construcție care conține o linie sau o muchie și este perpendicular pe un plan sau o față. Folosiți-l pentru a schița vertical pe un model, de-a lungul unei muchii.", + "help.construct.plane.tangent": "Creează un plan de construcție care atinge un cilindru, un con, o sferă sau un tor într-un punct. Folosiți-l pentru a schița pe latura unei piese rotunde.", + "help.construct.plane.three_points": "Creează un plan de construcție prin trei puncte. Folosiți-l pentru un plan stabilit de poziții de pe model, de exemplu trei colțuri.", + "help.construct.plane.two_edges": "Creează un plan de construcție prin două muchii drepte sau linii care se află în același plan. Folosiți-l pentru un plan prin mijlocul unei piese, de exemplu prin două muchii opuse.", + "help.construct.point.along_path": "Creează un punct de construcție într-o poziție de-a lungul unei muchii sau curbe. Folosiți-l ca referință la o anumită distanță pe un traseu.", + "help.construct.point.center": "Creează un punct de construcție în centrul unei muchii circulare, al unui cerc sau arc de schiță, al unei sfere sau al unui tor. Folosiți-l ca referință pentru găuri, axe și îmbinări.", + "help.construct.point.edge_plane": "Creează un punct de construcție acolo unde o muchie sau o linie trece printr-un plan sau o față plană. Folosiți-l pentru a marca locul unde un traseu traversează un plan.", + "help.construct.point.three_planes": "Creează un punct de construcție acolo unde se întâlnesc trei plane sau fețe plane. Folosiți-l ca referință de colț, chiar și acolo unde modelul nu are vârf.", + "help.construct.point.two_edges": "Creează un punct de construcție acolo unde două muchii sau linii se întâlnesc sau se apropie cel mai mult. Folosiți-l ca referință la un colț virtual.", + "help.construct.point.vertex": "Creează un punct de construcție pe un vârf sau pe un punct de schiță. Folosiți-l ca referință care rămâne când schița sau corpul din jurul lui sunt ascunse.", + "help.contact.check": "Listează perechile de piese din seturile de contact care se întrepătrund în poziția lor curentă. Folosiți-o pentru a găsi coliziuni înainte de a acționa îmbinările sau de a rula un studiu de mișcare.", + "help.contact.create": "Grupează două sau mai multe piese într-un set de contact, ale cărui corpuri nu se pot întrepătrunde cât timp se mișcă îmbinările. Folosiți-o pentru piese care trebuie să se oprească la atingere, de exemplu o pârghie lângă opritorul ei.", + "help.contact.disable_all": "Dezactivează contactul, astfel încât îmbinările acționate și studiile de mișcare să nu se mai oprească acolo unde piesele se ating. Folosiți-o pentru a mișca din nou piesele liber sau pentru a menține receptiv un ansamblu mare.", + "help.contact.edit": "Modifică un set de contact: piesele pe care le conține, numele lui sau dacă este suprimat; poate și șterge setul. Folosiți-o pentru a ajusta ce piese nu se pot întrepătrunde.", + "help.contact.enable_all": "Pune toate piesele ansamblului într-un singur set de contact, astfel încât două piese să nu se poată întrepătrunde cât timp se mișcă îmbinările. Folosiți-o pentru o verificare rapidă a unui mecanism întreg.", + "help.contact.enable_sets": "Activează contactul pentru seturile de contact pe care le-ați definit, astfel încât îmbinările acționate și studiile de mișcare să se oprească acolo unde piesele lor s-ar întrepătrunde. Folosiți-o pentru a readuce seturile după dezactivarea contactului.", + "help.contact.list": "Listează seturile de contact ale proiectului, piesele din fiecare și dacă contactul este activ. Folosiți-o pentru a verifica cum este configurat contactul înainte de a acționa ansamblul.", + "help.document.inspect": "Arată o privire de ansamblu asupra proiectului: componentele, corpurile, schițele, operațiile și parametrii lui. Folosiți-o pentru a vă orienta într-un proiect pe care nu l-ați construit dumneavoastră.", + "help.document.units": "Schimbă unitățile în care proiectul afișează lungimile, de exemplu mm sau inchi. Geometria își păstrează dimensiunea; doar afișarea și numerele simple urmează unitatea.", + "help.edit.checkpoint": "Reține proiectul, schița activă și istoricul de anulare așa cum sunt acum. Folosiți-l înainte de a încerca ceva riscant, de la care s-ar putea să vreți să vă întoarceți.", + "help.edit.restore_checkpoint": "Revine la un punct de control, inclusiv la istoricul de anulare. Folosiți-o pentru a renunța dintr-un singur pas la tot ce s-a făcut de la punctul de control.", + "help.explode.create": "Creează o vedere explodată care îndepărtează piesele de centrul ansamblului sau după mutările stabilite de dumneavoastră. Folosiți-o pentru a arăta cum se asamblează un ansamblu, pentru instrucțiuni sau prezentări.", "help.face.delete": "Elimină fețele selectate și lasă fețele vecine să închidă golul sau lasă golul deschis ca suprafață. Folosiți-l pentru a îndepărta o muchie rotunjită, o gaură sau un bosaj care incomodează.", + "help.file.insert_mesh": "Adaugă un fișier 3MF, STL, OBJ, PLY sau glTF în proiect ca corpuri plasă într-o componentă nouă. Folosiți-o pentru a plasa lângă proiectul dumneavoastră, ca referință, un model descărcat sau scanat.", + "help.file.insert_step": "Adaugă un fișier STEP, IGES, FreeCAD sau Rhino în proiect ca corpuri solide într-o componentă nouă; piesele unui ansamblu devin componente imbricate. Folosiți-o pentru a aduce modele de la furnizori sau din alte programe CAD.", + "help.file.save_mesh": "Salvează corpurile ca plasă STL, 3MF sau OBJ, cu netezimea fețelor curbe pe care o alegeți. Folosiți-o pentru a pregăti piese pentru imprimarea 3D sau pentru programe care citesc plase.", + "help.file.send_to_slicer": "Salvează corpurile selectate ca fișier 3MF și îl deschide în slicerul pe care îl alegeți. Folosiți-o pentru a trece de la model la imprimare într-un singur pas.", "help.font_details": "Interfața folosește fonturile încorporate ale egui (Ubuntu Light și Hack, sub Ubuntu Font Licence și licența MIT/Bitstream Vera; Noto Emoji și fontul de pictograme emoji, sub OFL și MIT). Textul din schiță folosește Liberation Sans (SIL OFL 1.1). Pachetul opțional de fonturi al Crafting Apps, craft-fonts (BIZ UDPGothic, Shippori Mincho, BIZ UDMincho, Noto Sans CJK SC, Noto Sans Arabic; SIL OFL 1.1), nu este inclus în versiunile SolidCraft.", "help.fonts": "Fonturi", + "help.frame.member": "Plasează un profil, de exemplu o țeavă sau un profil extrudat din aluminiu, de-a lungul liniilor unei schițe de cadru, câte un element pe linie. Folosiți-o pentru a construi cadre, rafturi și structuri sudate.", + "help.inspect.accessibility": "Colorează fețele pe care o sculă le poate atinge dintr-o direcție și pe cele pe care nu le poate atinge. Folosiți-o pentru a verifica o piesă pentru prelucrare sau matrițare din acea parte.", + "help.inspect.center_of_mass": "Arată centrul de masă al corpurilor, fiecare separat și toate împreună, la densitate uniformă. Folosiți-o pentru a verifica echilibrul și locul unde se răstoarnă o piesă.", + "help.inspect.colored_components": "Dă fiecărei componente propria culoare temporară, fără a modifica aspectele. Folosiți-o pentru a deosebi dintr-o privire piesele unui ansamblu.", + "help.inspect.compare": "Compară două corpuri și arată unde unul are material pe care celălalt nu îl are, cu volumul și abaterea. Folosiți-o pentru a verifica o piesă modificată sau importată față de original.", + "help.inspect.continuity": "Verifică cât de neted se întâlnesc fețele de-a lungul muchiilor: spații, frângeri sau salturi de curbură, și colorează cusăturile. Folosiți-o pentru a găsi unde suprafețele nu se îmbină curat.", + "help.inspect.curvature_comb": "Desenează piepteni de-a lungul curbelor, muchiilor sau fețelor, ai căror dinți arată cât de tare se curbează acestea. Folosiți-o pentru a depista porțiuni plate, frângeri și curbură neuniformă.", + "help.inspect.curvature_map": "Colorează fețele după cât de tare se curbează, cu roșu unde raza este mică. Folosiți-o pentru a găsi locurile strânse și a aprecia cât de netedă este o suprafață.", + "help.inspect.draft": "Colorează fețele după unghiul lor de înclinare față de o direcție de extragere, marcându-le pe cele cu prea puțină înclinare. Folosiți-o pentru a verifica o piesă înainte de matrițare sau turnare.", + "help.inspect.environment_map": "Arată modelul cu un finisaj reflectant, ca de oglindă. Folosiți-o pentru a aprecia curgerea suprafețelor după felul în care se desfășoară reflexiile pe ele.", + "help.inspect.interference": "Găsește corpurile care se suprapun și arată fiecare suprapunere cu volumul ei. Folosiți-o pentru a verifica dacă un ansamblu are piese care se ciocnesc.", + "help.inspect.isocurve": "Desenează curbele izoparametrice ale fețelor, adică grila pe care este construită suprafața. Folosiți-o pentru a vedea cum curge o suprafață și unde se îngrămădește.", + "help.inspect.measure": "Măsoară lungimi, distanțe, unghiuri, arii și raze între elementele alese sau volumul și masa corpurilor. Folosiți-o pentru a verifica dimensiuni și jocuri.", + "help.inspect.minimum_radius": "Găsește cea mai mică rază de curbură a curbelor, muchiilor sau fețelor și locul unde apare. Folosiți-o pentru a verifica dacă o sculă sau o duză poate urmări forma.", + "help.inspect.section": "Taie vizualizarea de-a lungul unui plan și hașurează fețele tăiate, fără a modifica modelul. Folosiți-o pentru a privi în interiorul pieselor și ansamblurilor.", + "help.inspect.zebra": "Arată dungi albe și negre reflectate pe fețele modelului. Folosiți-o pentru a aprecia netezimea: dungile care se rup sau se frâng la o muchie arată o tranziție slabă.", + "help.joint.as_built": "Îmbină două piese acolo unde se află deja, fără a muta niciuna, și stabilește cum se mai pot mișca. Folosiți-o pentru piese pe care le-ați plasat deja în poziția finală.", + "help.joint.cam": "Leagă o camă rotitoare de un tachet, astfel încât tachetul să alunece sau să oscileze pe măsură ce conturul camei trece pe sub el. Folosiți-o pentru came, excentrice și mecanisme de acționare a supapelor.", + "help.joint.create": "Îmbină două piese la originile îmbinării alese și mută a doua piesă la locul ei; tipul îmbinării stabilește cum se mai pot mișca. Folosiți-o pentru a construi un ansamblu ale cărui piese se mișcă precum mecanismul real.", + "help.joint.drive": "Stabilește unghiul sau distanța unei îmbinări și mută piesele odată cu ea, în limitele ei; îmbinările legate și buclele închise o urmează. Folosiți-o pentru a verifica dacă un mecanism se mișcă așa cum trebuie.", + "help.joint.gear": "Leagă două îmbinări rotitoare printr-un raport de transmisie, astfel încât acționarea uneia o rotește pe cealaltă. Folosiți-l pentru roți dințate cilindrice și interioare, cu raportul dat de razele cercurilor de divizare sau de numărul de dinți.", + "help.joint.limits": "Stabilește cât se poate roti sau aluneca o îmbinare, ca minim și maxim, sau șterge aceste limite. Folosiți-o pentru a ține o balama, un cursor sau o pârghie în cursa ei reală.", + "help.joint.motion_link": "Cuplează două îmbinări cu un raport și un decalaj, astfel încât acționarea uneia o mișcă pe cealaltă. Folosiți-o pentru o relație fixă între îmbinări pe care Angrenaj, Cremalieră și pinion sau Șurub nu o acoperă.", + "help.joint.origin": "Plasează pe o piesă o origine de îmbinare denumită: în centrul unei fețe sau al unui capăt rotund, la mijlocul unei muchii, într-un colț sau într-un punct. Folosiți-o pentru a pregăti locurile unde se prind îmbinările; una aleasă pe o față o urmează când piesa își schimbă dimensiunea.", + "help.joint.rack_pinion": "Leagă un pinion rotitor de o cremalieră glisantă, astfel încât cremaliera se mișcă pe lungimea cercului de divizare pe care se rostogolește pinionul. Folosiți-o pentru acționări cu cremalieră, mecanisme de direcție și module liniare.", + "help.joint.reach": "Mută un punct de pe o piesă spre o țintă schimbând îmbinările lanțului său, în limitele lor. Folosiți-o pentru a poziționa un mecanism după locul unde trebuie să ajungă capătul lui, de exemplu vârful unui braț de robot.", + "help.joint.rigid_group": "Blochează împreună piesele selectate așa cum sunt, astfel încât să se miște ca una singură. Folosiți-l pentru piese care aparțin laolaltă și între care nu există mișcare.", + "help.joint.screw": "Leagă rotirea și alunecarea printr-un pas, astfel încât fiecare rotație avansează cu acea distanță. Folosiți-o pentru șuruburi conducătoare, șuruburi și axe filetate.", + "help.library.apply": "Aplică un material sau un aspect dintr-un fișier de bibliotecă locală corpurilor, fețelor sau componentelor. Folosiți-o pentru a reutiliza materiale salvate dintr-un alt proiect.", + "help.library.export": "Salvează materialele și aspectele proiectului într-un fișier de bibliotecă. Folosiți-o pentru a le partaja sau a le reutiliza în alte proiecte.", + "help.library.inspect": "Verifică un fișier de bibliotecă și îi listează materialele și aspectele, fără a modifica proiectul. Folosiți-o pentru a vedea ce conține o bibliotecă înainte de a o aplica.", "help.licences": "Licențe", "help.material.assign": "Stabilește din ce sunt făcute corpurile selectate, de exemplu oțel, aluminiu sau ABS. Folosiți-l pentru ca masa și greutatea să corespundă piesei reale.", + "help.material.properties.validate": "Verifică dacă fișa de date a unui material are numere coerente și dacă îi lipsesc valori. Folosiți-o înainte de a vă baza pe date pentru masă sau rigiditate.", + "help.mesh.convert": "Reconstruiește un corp plasă, de exemplu un STL, ca solid editabil cu fețe plane și cilindrice. Folosiți-o pentru a modifica piese mecanice importate ca pe un corp modelat.", "help.mit": "Licența MIT", + "help.motion.study": "Stabilește valorile îmbinărilor la pași pe o cronologie și le redă ca animație. Folosiți-l pentru a urmări un mecanism parcurgând o secvență și a verifica jocurile pe parcurs.", + "help.motion.trace": "Urmărește drumul parcurs de un punct al unei piese în timp ce rulează un studiu de mișcare sau se acționează o îmbinare și îl păstrează ca o curbă de schiță 3D când confirmați. Folosiți-o pentru a vedea unde ajunge un mecanism sau pentru a construi pe traseul lui.", "help.notice": "NOTICE", + "help.occurrence.ground": "Fixează o piesă pe loc sau o eliberează din nou, astfel încât îmbinările să miște celelalte piese în jurul ei. Folosiți-o pentru cadrul sau baza pe care este construit restul ansamblului.", "help.occurrence.move": "Mută o piesă din ansamblu doar pe direcțiile de mișcare permise de îmbinările sale. Selectați o față a piesei, apoi trageți o săgeată sau un arc disponibil.", "help.parameters.change": "Deschide tabelul cu valori denumite și expresii care controlează cotele proiectului. Folosiți-l pentru a schimba o dimensiune într-un singur loc și a lăsa toate operațiile care o folosesc să o urmeze.", + "help.parameters.export": "Salvează parametrii proiectului ca CSV sau JSON. Folosiți-o pentru a-i edita într-o foaie de calcul sau a-i muta într-un alt proiect.", + "help.parameters.graph": "Arată ce parametri și ce operații le folosesc pe celelalte, precum și eventualele bucle sau erori dintre ele. Folosiți-l pentru a vedea ce va afecta modificarea unui parametru.", + "help.parameters.import": "Citește parametri din CSV sau JSON în proiect, pe toți sau deloc. Folosiți-o pentru a aduce valori dintr-o foaie de calcul sau dintr-un alt proiect.", + "help.parameters.move": "Mută un parametru utilizator înaintea sau după altul în listă. Folosiți-o pentru a ține împreună parametrii înrudiți.", + "help.parts.fastener": "Adaugă un șurub, un bolț, o piuliță, o șaibă, un știft sau un rulment standard, în dimensiunea aleasă. Plasat pe o gaură, se așază în gaură printr-o îmbinare, așa că nu trebuie să modelați singur elementele de fixare.", + "help.parts.insert": "Inserează o piesă standard din familia, dimensiunea și lungimea alese. Plasată pe marginea sau peretele unei găuri, se așază în gaură printr-o îmbinare rigidă.", + "help.parts.library": "Deschide piesele standard: șuruburi, bolțuri, piulițe, șaibe, șuruburi de fixare, știfturi de centrare și rulmenți, în dimensiuni standard. Folosiți-o pentru a adăuga o piesă standard în loc să o modelați singur.", + "help.parts.resize": "Schimbă dimensiunea sau lungimea unei piese standard inserate și o reconstruiește; îmbinările ei o păstrează așezată. Folosiți-o când un șurub se dovedește prea lung sau o gaură cere altă dimensiune.", + "help.plastic.assign_rule": "Atribuie o regulă pentru plastic unor corpuri, astfel încât operațiile lor pentru plastic să preia înclinarea și jocul ei ca valori implicite. Folosiți-o pentru a configura o singură dată o piesă matrițată pentru toate operațiile ei.", + "help.plastic.boss": "Adaugă pe o față un bosaj pentru șurub: un stâlp cu gaură opțională, înclinare, racordare la bază și nervuri de susținere. Folosiți-l pentru punctele de șurub din carcase matrițate sau imprimate.", + "help.plastic.lip": "Adaugă o buză sau o canelură potrivită pe marginea unui corp scobit. Folosiți-o pentru ca două jumătăți de carcasă să se centreze una pe alta și să se închidă strâns.", + "help.plastic.manage_rules": "Creează și editează reguli pentru plastic: materialul, grosimea peretelui, razele, jocul și înclinarea. Folosiți-le pentru a menține coerente operațiile unei piese matrițate.", + "help.plastic.rest": "Adaugă un reazem pe o față: o platformă rotundă sau dreptunghiulară, plină sau goală, cu înclinare. Folosiți-l ca sprijin sau distanțier într-o carcasă matrițată.", + "help.plastic.snap_fit": "Adaugă pe o față un cârlig de prindere cu clichet, cu lungimea, grosimea și zăvorul lui. Folosiți-o pentru a prinde piesele între ele fără șuruburi.", "help.pointcloud.insert": "Inserați o scanare PLY, XYZ sau PTS ca nor de puncte alături de model. Folosiți-o ca referință la construirea unui model pe baza datelor măsurate.", + "help.sheet.convert": "Transformă o placă sau un corp scobit de grosime uniformă într-un corp din tablă, cu îndoiri la muchiile alese. Folosiți-o pentru a face desfășurabilă o piesă existentă.", + "help.sheet.export_bend_table": "Salvează tabelul de îndoire al unui corp din tablă ca CSV sau JSON: unghiuri, raze și poziții ale îndoirilor în desfășurată. Folosiți-o pentru presa de îndoit sau pentru atelier.", + "help.sheet.export_dxf": "Salvează desfășurata ca DXF, cu contururi, linii de îndoire și note de îndoire opționale. Folosiți-o pentru tăierea cu laser, cu plasmă sau cu jet de apă.", + "help.sheet.flange": "Adaugă o flanșă: o placă de bază dintr-o schiță, un perete de-a lungul unui contur deschis de schiță sau un perete îndoit pe muchii de tablă. Folosiți-o pentru a construi piese din tablă.", + "help.sheet.flat_pattern": "Creează desfășurata plană a unui corp din tablă, cu dimensiunea, decupările și liniile de îndoire. Folosiți-o pentru a verifica semifabricatul înainte de tăiere.", + "help.sheet.fold": "Îndoaie o tablă de-a lungul unei linii de schiță cu un unghi și o rază. Folosiți-o pentru a îndoi o tablă plană după ce ați adăugat în ea decupări.", + "help.sheet.hem": "Îndoaie o muchie de tablă peste ea însăși. Folosiți-o pentru muchii sigure și rigide, fără bavuri ascuțite.", + "help.sheet.manage_rules": "Creează și editează reguli pentru tablă: grosime, rază de îndoire, factor K, degajări și spații. Folosiți-le pentru a vă adapta la materialul și sculele atelierului dumneavoastră.", + "help.sheet.path_flange": "Adaugă un lanț de flanșe de-a lungul unei muchii de tablă, urmând un lanț deschis de linii de schiță. Folosiți-o pentru profile precum forma U sau Z dintr-un singur pas.", + "help.sheet.perforate": "Adaugă un câmp de găuri pe toată tabla desfășurată, într-un model decalat sau pătrat. Folosiți-o pentru ventilații, grilaje și panouri mai ușoare.", + "help.sheet.refold": "Îndoaie la loc un corp din tablă desfășurat. Folosiți-o după ce ați adăugat operații pe tabla plană.", + "help.sheet.unfold": "Desfășoară toate îndoirile unui corp din tablă. Folosiți-o pentru a adăuga găuri sau decupări care trebuie să traverseze o îndoire, apoi reîndoiți.", + "help.sketch.arc.center_point": "Desenează un arc de cerc în jurul unui punct central, de la un punct de început la unul de sfârșit. Folosiți-l când centrul arcului este cunoscut, de exemplu o rază în jurul unei găuri sau al unui pivot.", + "help.sketch.arc.tangent": "Desenează un arc care pornește de la capătul unei linii sau al unui arc și continuă tangent la acesta. Folosiți-l pentru a continua un lanț cu o curbă lină în loc de un colț.", + "help.sketch.arc.three_point": "Desenează un arc de la un punct de început la unul de sfârșit, printr-un al treilea punct. Folosiți-l când arcul trebuie să treacă prin puncte date, nu în jurul unui centru cunoscut.", + "help.sketch.arc3d": "Desenează un arc de cerc exact în spațiu prin trei puncte, cu Schiță 3D activată. Folosiți-l pentru curbe în traseele și ghidajele care ies din planul schiței.", + "help.sketch.auto_constrain": "Adaugă constrângerile pe care schița le are aproape deja, de exemplu orizontală, paralelă sau coincidentă, apoi o cotează până este complet definită. Folosiți-o pentru a bloca dintr-un singur pas o schiță desenată aproximativ.", + "help.sketch.auto_constrain_from_datum": "Funcționează ca Constrângere automată, dar măsoară pozițiile față de un reper ales de dumneavoastră, nu față de origine. Folosiți-o când piesa este trasată dintr-un singur punct de referință, de exemplu un colț sau o gaură.", + "help.sketch.blend_curve": "Unește capetele a două curbe cu un spline lin, tangent la amândouă sau, la alegere, și cu curbură continuă. Folosiți-o pentru a acoperi un spațiu dintre curbe fără frângere.", + "help.sketch.break": "Împarte o curbă în bucăți acolo unde o taie alte curbe sau în punctele pe care le plasați pe ea. Folosiți-o când o parte a unei curbe are nevoie de propria constrângere sau trebuie ștearsă separat.", + "help.sketch.cancel": "Părăsește schița și renunță la tot ce s-a schimbat de când a fost creată sau deschisă pentru editare. Anularea ultimei acțiuni readuce modificările.", + "help.sketch.centerline": "Comută liniile între linii normale și linii de axă, adică axe pe care profilurile le omit. Folosiți o linie de axă ca axă pentru o revoluție, o oglindire sau o constrângere de simetrie.", + "help.sketch.chamfer.distance_angle": "Teșește colțul dintre două linii, stabilit printr-o distanță pe prima linie și un unghi. Folosiți-o când un desen dă teșitura ca lungime și unghi, de exemplu 2 mm × 30°.", + "help.sketch.chamfer.equal_distance": "Teșește colțul dintre două linii cu aceeași distanță pe ambele. Folosiți-o pentru teșitura obișnuită de 45° pe un contur.", + "help.sketch.chamfer.two_distance": "Teșește colțul dintre două linii, stabilit printr-o distanță separată pe fiecare linie. Folosiți-o pentru teșituri care nu sunt de 45°, date ca două lungimi.", + "help.sketch.circle.center": "Desenează un cerc după centrul și diametrul lui. Folosiți-l pentru găuri, bosaje și alte elemente rotunde al căror centru este cunoscut.", + "help.sketch.circle.three_point": "Desenează un cerc prin trei puncte. Folosiți-l când cercul trebuie să treacă prin puncte date, de exemplu ale geometriei existente.", + "help.sketch.circle.three_tangent": "Desenează un cerc care atinge trei linii, cercuri sau arce. Folosiți-l pentru a potrivi un cerc în spațiul dintre trei curbe, de exemplu cercul înscris al unui triunghi.", + "help.sketch.circle.two_point": "Desenează un cerc din cele două capete ale diametrului său. Folosiți-l când sunt cunoscute punctele de pe marginea cercului, nu centrul.", + "help.sketch.circle.two_tangent": "Desenează un cerc de o dimensiune dată care atinge două linii, cercuri sau arce. Folosiți-l pentru o rotunjire de rază fixă între două curbe.", + "help.sketch.conic": "Desenează o curbă conică între două puncte finale, modelată de un punct de vârf și o valoare rho. Folosiți-o pentru tranziții line a căror plinătate o reglați, de la plată până aproape de un colț ascuțit.", + "help.sketch.constrainer": "Aplică constrângerea pe care una sau două curbe alese o au aproape deja, de exemplu orizontală, paralelă, tangentă sau egală. Folosiți-o pentru a pune în ordine o schiță desenată de mână, câte o relație pe rând.", + "help.sketch.constraint.coincident": "Face ca un punct să se afle pe alt punct, pe o curbă sau pe o axă a schiței. Folosiți-o pentru a închide spațiile dintr-un profil sau a pune un punct pe o linie.", + "help.sketch.constraint.collinear": "Face ca două linii să se afle pe aceeași dreaptă. Folosiți-o pentru a alinia muchii întrerupte, de exemplu ambele laturi ale unei crestături.", + "help.sketch.constraint.concentric": "Dă la două cercuri sau arce același centru. Folosiți-o pentru inele, lamaje și arbori cu umeri.", + "help.sketch.constraint.curvature": "Face ca două curbe care se întâlnesc într-un punct final să continue cu aceeași curbură (G2). Folosiți-o acolo unde un racord tangent nu este destul de neted, de exemplu pe suprafețe vizibile.", + "help.sketch.constraint.equal": "Face liniile de aceeași lungime sau cercurile și arcele de aceeași rază. Folosiți-o pentru ca o singură cotă să conducă mai multe elemente identice.", + "help.sketch.constraint.fix": "Fixează puncte sau curbe unde se află sau le eliberează din nou. Folosiți-o pentru geometria care nu trebuie să se miște când trageți sau rezolvați schița.", + "help.sketch.constraint.horizontal_vertical": "Face o linie, sau linia dintre două puncte, orizontală sau verticală, după care este mai apropiată. Folosiți-o pentru a aduce un contur în unghi drept pe axele schiței.", + "help.sketch.constraint.midpoint": "Pune un punct la mijlocul unei linii sau face ca mijloacele a două linii să coincidă. Folosiți-o pentru a centra geometria pe o muchie.", + "help.sketch.constraint.parallel": "Face două linii paralele. Folosiți-o pentru laturi care trebuie să rămână paralele când se schimbă alte cote.", + "help.sketch.constraint.perpendicular": "Face două linii să se întâlnească în unghi drept. Folosiți-o pentru colțuri drepte între linii care nu se află pe axele schiței.", + "help.sketch.constraint.polygon": "Face dintr-un lanț închis de linii un poligon regulat: laturi egale, cu fiecare colț pe același cerc. Folosiți-o pentru a face regulat un poligon desenat de mână și a-l menține așa.", + "help.sketch.constraint.symmetry": "Face două puncte sau curbe să fie în oglindă față de o linie sau în jurul unui punct. Folosiți-o pentru a menține un profil simetric când schimbați o parte.", + "help.sketch.constraint.tangent": "Face două curbe să se atingă tangent, de exemplu o linie și un arc. Folosiți-o pentru racorduri line, fără frângere.", + "help.sketch.construction": "Comută curbele între geometrie normală și geometrie de construcție, pe care profilurile o omit. Folosiți curbele de construcție ca ajutoare pentru trasare, referințe și cote.", "help.sketch.construction_line": "Creați o dreaptă de construcție infinită prin două puncte. Folosiți-o pentru a structura o schiță fără a diviza profilurile.", "help.sketch.construction_line.angle": "Creați o dreaptă de construcție infinită sub un unghi față de axa X a schiței. Alegeți punctul și tastați unghiul sau alegeți direcția acestuia.", "help.sketch.construction_line.bisector": "Alegeți două linii drepte ale schiței care se intersectează, apoi una dintre cele două bisectoare. Rezultatul urmărește liniile sursă.", @@ -1412,11 +1564,71 @@ "help.sketch.construction_line.tangent_external": "Alegeți un cerc sau un arc de cerc, apoi un punct exterior și o soluție de tangentă. Previzualizările arată doar punctele de tangență de pe arcul desenat.", "help.sketch.construction_line.tangent_two": "Alegeți două cercuri sau arce de cerc, apoi o tangentă comună în previzualizare. Punctele de tangență trebuie să se afle pe curbele desenate.", "help.sketch.construction_line.vertical": "Creați o dreaptă de construcție verticală infinită printr-un punct. Constrângerea verticală rămâne valabilă la editările ulterioare.", + "help.sketch.copy": "Copiază curbele și punctele de schiță selectate, împreună cu constrângerile și cotele dintre ele. Lipiți-le în această schiță sau în alta.", + "help.sketch.create": "Începe o schiță 2D nouă pe un plan sau pe o față plană a modelului. Orice extrudare, revoluție și baleiere începe cu o schiță.", + "help.sketch.cut": "Copiază geometria de schiță selectată și o șterge din schiță, într-un singur pas de anulare. Lipiți-o în altă parte din această schiță sau într-o alta.", + "help.sketch.dimension": "Adaugă o cotă conducătoare: o lungime, o distanță, un unghi, o rază sau un diametru, după ce alegeți. Tastați o valoare sau un nume de parametru, iar schița o urmează.", + "help.sketch.edit": "Deschide o schiță existentă pentru editare. Operațiile construite pe ea se actualizează când finalizați schița.", + "help.sketch.ellipse": "Desenează o elipsă după centrul ei, capătul axei mari și un punct pe latura mică. Folosiți-o pentru deschideri ovale, came și alte forme rotunde alungite.", "help.sketch.equation_curve": "Desenează o curbă din formule: x(t) și y(t) sau y = f(x), pe un interval. Formulele pot folosi parametri, iar curba îi urmează: folosiți-o pentru o sinusoidă, o spirală sau evolventa unui dinte de roată dințată.", + "help.sketch.extend": "Prelungește o linie sau un arc până la cea mai apropiată curbă din calea lui sau extinde două curbe până se întâlnesc. Folosiți-o pentru a închide spațiile dintr-un profil după tăiere sau editare.", + "help.sketch.fillet": "Racordează colțul în care se întâlnesc două linii sau arce cu un arc de rază dată. Mai multe colțuri racordate împreună au aceeași rază.", + "help.sketch.finish": "Părăsește schița și revine la model. Profilurile ei închise sunt apoi gata pentru Extrudare, Revoluție și alte operații.", + "help.sketch.finish_auto_constrain": "Rulează Constrângere automată pe schiță, apoi o finalizează. Folosiți-o pentru a lăsa o schiță complet definită dintr-un singur pas.", + "help.sketch.fit_curves_to_section": "Taie un corp plasă sau solid cu planul schiței și potrivește linii și arce pe secțiune. Folosiți-o pentru a reconstrui o piesă scanată sau importată ca schiță editabilă.", + "help.sketch.include_3d": "Aduce în această schiță muchii ale modelului, vârfuri și curbe ale altor schițe, ca referințe legate, aplatizându-le pe plan pe cele aflate în afara lui. Folosiți-o pentru a construi pe geometrie care nu se află în planul schiței.", + "help.sketch.infinite": "Comută liniile între linii normale și linii infinite de construcție, care traversează toată vizualizarea. Folosiți-le ca referințe de trasare care nu se termină niciodată prea devreme.", + "help.sketch.insert_dxf": "Citește curbele unui desen DXF într-o schiță, scalate, rotite și plasate așa cum stabiliți. Folosiți-o pentru a construi pe un desen 2D din alt program, de exemplu un contur pentru tăiere cu laser.", + "help.sketch.insert_svg": "Citește traseele unui desen SVG într-o schiță, scalate, rotite și plasate așa cum stabiliți. Folosiți-o pentru a transforma un logo sau un contur vectorial în curbe pe care le puteți extruda.", + "help.sketch.inspect": "Deschide un raport despre schița deschisă: câte grade de libertate au rămas și dacă este complet constrânsă. Folosiți-l pentru a afla ce se mai mișcă înainte de a finaliza schița.", + "help.sketch.intersect": "Adaugă curbele sau punctele în care corpuri, fețe, muchii sau alte schițe taie planul schiței. Rămân legate și urmează modelul când acesta se schimbă.", + "help.sketch.intersection_curve": "Creează curbe 3D acolo unde se întâlnesc două corpuri sau fețe, legate de ele. Folosiți-le ca trasee sau ghidaje care urmează modelul.", + "help.sketch.isoparametric_curve": "Creează o curbă 3D pe direcția U sau V a unei fețe, printr-un punct ales de dumneavoastră, legată de față. Folosiți-o ca traseu sau linie de separare care urmează suprafața.", + "help.sketch.line": "Desenează linii conectate punct cu punct, ca lanț deschis sau contur închis. Este instrumentul de bază pentru majoritatea profilurilor.", + "help.sketch.line.midpoint": "Desenează o linie de la mijlocul ei spre un capăt, celălalt capăt fiind în oglindă. Folosiți-o pentru linii centrate pe un punct, de exemplu o muchie simetrică.", + "help.sketch.line3d": "Desenează linii drepte conectate între puncte din spațiu, cu Schiță 3D activată. Folosiți-o pentru traseele și cadrele care ies din planul schiței.", + "help.sketch.mirror": "Oglindește curbele și punctele de schiță față de o linie sau o axă și adaugă constrângeri de simetrie. Folosiți-o pentru a desena jumătate dintr-un profil simetric și a-l completa.", + "help.sketch.move": "Mută sau rotește curbe și puncte de schiță, ori o copie a lor. Folosiți-o pentru a repoziționa geometria, păstrându-i forma și constrângerile.", + "help.sketch.offset": "Creează o copie decalată a unui lanț de curbe, la o distanță dată, pe o parte sau pe ambele. Folosiți-o pentru pereți, contururi și jocuri; distanța rămâne o cotă editabilă.", + "help.sketch.paste": "Lipește în schița activă geometria de schiță pe care ați copiat-o sau decupat-o. Plasați-o într-un punct sau deplasați-o cu un decalaj.", + "help.sketch.pattern.circular": "Copiază geometria schiței în jurul unui punct central, pe un cerc complet sau pe un unghi dat. Folosiți-o pentru cercuri de șuruburi, spițe și dinți.", + "help.sketch.pattern.rectangular": "Copiază geometria schiței în rânduri și coloane, la distanțe stabilite. Folosiți-o pentru grile de găuri, canale sau ventilații.", + "help.sketch.point": "Plasează un punct de schiță. Folosiți-l ca referință pentru cote, constrângeri, centre de găuri sau operații ulterioare.", + "help.sketch.point3d": "Plasează un punct în spațiu, cu Schiță 3D activată. Folosiți-l ca referință pentru curbe și trasee 3D.", + "help.sketch.polygon.circumscribed": "Desenează un poligon regulat în jurul unui cerc, dimensionat de la centru până la mijlocul unei laturi. Folosiți-l când este cunoscută deschiderea cheii, ca la o piuliță hexagonală.", + "help.sketch.polygon.edge": "Desenează un poligon regulat din cele două capete ale unei laturi. Folosiți-l când sunt cunoscute lungimea și poziția unei laturi.", + "help.sketch.polygon.inscribed": "Desenează un poligon regulat în interiorul unui cerc, dimensionat de la centru până la un colț. Folosiți-l când este cunoscută dimensiunea între colțuri.", + "help.sketch.project": "Aduce în această schiță muchii ale modelului, fețe, vârfuri și curbe ale altor schițe, proiectate pe planul ei. Rămân legate și urmează modelul când acesta se schimbă.", + "help.sketch.project_to_surface": "Proiectează curbe ale altor schițe de-a lungul normalei acestei schițe pe o față, ca curbe 3D legate. Folosiți-o pentru a pune un logo, o linie de separare sau un traseu pe o suprafață curbă.", + "help.sketch.rectangle.center": "Desenează un dreptunghi după centrul și un colț al lui. Folosiți-l pentru contururi centrate pe un punct, de exemplu originea.", + "help.sketch.rectangle.rounded": "Desenează un dreptunghi cu toate cele patru colțuri rotunjite la aceeași rază. Folosiți-l pentru plăci, capace și deschideri cu colțuri rotunjite.", + "help.sketch.rectangle.three_point": "Desenează un dreptunghi dintr-o muchie și un punct de pe latura opusă. Folosiți-l pentru dreptunghiuri înclinate față de axele schiței.", + "help.sketch.rectangle.two_point": "Desenează un dreptunghi din două colțuri opuse. Folosiți-l pentru contururile plăcilor, blocurilor și buzunarelor.", + "help.sketch.scale": "Scalează curbe și puncte de schiță cu un factor față de un punct de bază. Folosiți-o pentru a redimensiona o parte a unei schițe fără a o desena din nou.", + "help.sketch.slot.arc_center": "Desenează un canal curbat, de lățime dată, de-a lungul unui arc stabilit prin centru, început și sfârșit. Folosiți-l pentru canale de reglaj în jurul unui pivot.", + "help.sketch.slot.arc_three_point": "Desenează un canal curbat, de lățime dată, de-a lungul unui arc prin trei puncte. Folosiți-l când traseul canalului trebuie să treacă prin puncte date.", + "help.sketch.slot.center_point": "Desenează un canal drept, de lățime dată, de la centrul lui până la centrul unui arc de capăt. Folosiți-l pentru canale centrate pe un punct.", + "help.sketch.slot.center_to_center": "Desenează un canal drept, de lățime dată, între centrele celor două arce de capăt. Folosiți-l pentru canale cotate de la centru la centru, de exemplu canale de reglaj pentru șuruburi.", + "help.sketch.slot.overall": "Desenează un canal drept, de lățime dată, de la un capăt exterior la celălalt. Folosiți-l când contează lungimea totală a canalului.", + "help.sketch.spline.control_point": "Desenează un spline cubic modelat de puncte de control prin care nu trece. Folosiți-l pentru curbe line și fluide pe care le modelați mutând punctele.", + "help.sketch.spline.control_point_5": "Desenează un spline de gradul 5 modelat de puncte de control. Folosiți-l unde curbele trebuie să se racordeze cu o curbură mai netedă, de exemplu pe suprafețe stilizate vizibile.", + "help.sketch.spline.fit_point": "Desenează un spline neted care trece prin fiecare punct plasat de dumneavoastră, deschis sau închis. Folosiți-l pentru a urmări o formă prin puncte cunoscute.", + "help.sketch.spline3d": "Desenează o curbă netedă în spațiu prin puncte, cu Schiță 3D activată. Folosiți-l pentru trasee de baleiere și trasee de cabluri care ies din planul schiței.", + "help.sketch.spun_profile": "Creează în planul schiței conturul pe care îl descrie un corp când se rotește în jurul unei axe. Folosiți-l pentru a obține silueta unei piese strunjite pentru o revoluție sau un desen.", + "help.sketch.text": "Scrie text în schiță, cu un font, o înălțime și un unghi alese; cuvintele pot urma parametri. Extrudați-l sau reliefați-l pentru etichete și inscripții.", + "help.sketch.transform": "Scalează, rotește și mută geometria schiței în jurul unui punct central, dintr-un singur pas. Folosiți-o pentru a repoziționa și redimensiona deodată o selecție.", + "help.sketch.trim": "Elimină bucata unei curbe dintre cele mai apropiate intersecții din jurul locului unde faceți clic. Folosiți-o pentru a curăța liniile suprapuse până obțineți un profil închis.", "help.solid.align": "Mută corpurile selectate astfel încât un punct, o față sau un plan de pe ele să se întâlnească cu un punct, o față sau un plan din altă parte. Folosiți-l pentru a așeza o piesă lângă alta fără a măsura decalajul.", + "help.solid.boundary_fill": "Creează solide din regiunile închise de corpuri-sculă suprapuse, umplând doar regiunile pe care le alegeți. Folosiți-o pentru a construi o formă din spațiul delimitat împreună de mai multe corpuri.", + "help.solid.bounding_solid": "Creează o cutie care cuprinde corpurile selectate, cu o marjă opțională. Folosiți-l pentru a vedea dimensiunea generală a unei piese sau ca semifabricat din care se prelucrează.", + "help.solid.box": "Creează o cutie după lungime, lățime și înălțime, dintr-un punct de colț sau centrată pe un punct. Folosiți-o pentru un bloc rapid, un înlocuitor provizoriu sau un corp în care să tăiați operații.", "help.solid.chamfer": "Teșește muchiile selectate cu o înclinare plană la distanța stabilită. Folosiți-l pentru a îndepărta muchiile ascuțite sau pentru a ușura introducerea unui pin sau a unui șurub.", + "help.solid.coil": "Creează un corp elicoidal, de exemplu un arc, după diametru, spire, înălțime sau pas și o secțiune rotundă sau pătrată. Folosiți-o pentru arcuri, bobine și nervuri elicoidale.", "help.solid.combine": "Unește corpurile, le decupează dintr-un corp țintă sau păstrează doar volumul pe care îl au în comun și poate păstra corpurile-sculă. Folosiți-l pentru a îmbina piese, pentru a decupa o formă cu alta sau pentru a găsi unde se suprapun două corpuri.", + "help.solid.cone": "Creează un con după un cerc de bază și o înălțime sau un trunchi de con când și vârful are o rază. Folosiți-l pentru conicități, pâlnii, înfundări conice și vârfuri.", + "help.solid.cylinder": "Creează un cilindru după rază sau diametru și înălțime, așezat pe un punct de-a lungul unei axe. Folosiți-l pentru arbori, știfturi, bosaje și semifabricate rotunde.", "help.solid.draft": "Înclină fețele selectate cu unghiul stabilit, măsurat de la un plan neutru. Folosiți-l la piese turnate sau matrițate pentru ca ele să iasă din matriță.", + "help.solid.emboss": "Ridică profile de schiță, de exemplu text sau un logo, dintr-o față sau le adâncește în ea. Folosiți-o pentru inscripții, etichete și marcaje pe fețe plane sau rotunde.", "help.solid.extrude": "Împinge un profil de schiță în 3D pe o distanță, adăugând sau eliminând material. Folosiți-l pentru a transforma un contur plat într-un bloc, un bosaj sau un buzunar.", "help.solid.fillet": "Racordează muchiile selectate cu un arc de cerc neted, de raza stabilită. Folosiți-l pentru a rotunji muchiile ascuțite din motive de rezistență, confort sau aspect.", "help.solid.hole": "Execută o gaură rotundă de diametrul stabilit într-un punct ales al unei fețe. Folosiți-l pentru degajările șuruburilor, pentru pini și alte deschideri rotunde simple.", @@ -1425,20 +1637,60 @@ "help.solid.move": "Mută sau rotește corpurile selectate, ori o copie a lor, cu distanța și unghiul stabilite. Se folosește pentru a repoziționa un corp sau pentru a crea un al doilea corp în alt loc.", "help.solid.offset_face": "Împinge fețele selectate spre interior sau spre exterior cu distanța stabilită, iar fețele din jur le urmează. Se folosește pentru a îngroșa un perete, a adânci un buzunar sau a înălța un bosaj fără a edita schița.", "help.solid.pattern.circular": "Repetă obiectele selectate în jurul unei axe, pentru numărul și unghiul total stabilite. Se folosește pentru cercuri de șuruburi, spițe, aripioare și dinți.", + "help.solid.pattern.path": "Copiază operații sau corpuri de-a lungul unui traseu de schiță, după număr și distanță. Folosiți-o pentru găuri de-a lungul unei muchii curbe sau piese de-a lungul unei șine.", "help.solid.pattern.rectangular": "Repetă obiectele selectate pe una sau două direcții, cu numărul și spațierea stabilite. Se folosește pentru rânduri și grile de găuri, nervuri, știfturi sau picioare.", + "help.solid.pipe": "Creează un tub rotund de-a lungul unui traseu de curbe de schiță, plin sau gol, cu o grosime de perete. Folosiți-o pentru tuburi, fire, cabluri și șine.", "help.solid.press_pull": "Adaugă sau elimină material pe măsură ce trageți o față sau racordează muchiile pe măsură ce le trageți. Se folosește pentru modificări rapide direct pe model.", "help.solid.remove": "Elimină corpurile selectate din proiect începând de la acest punct al cronologiei; pașii anteriori le păstrează. Se folosește pentru a renunța la un corp auxiliar, de exemplu o sculă de tăiere de care nu mai aveți nevoie.", "help.solid.replace_face": "Mută fețele plane selectate pe un plan sau pe o față paralelă, iar corpul crește sau se micșorează pentru a ajunge la ea. Se folosește pentru a aduce o față la același nivel cu o altă piesă sau cu un plan de construcție.", "help.solid.revolve": "Rotește un profil de schiță în jurul unei axe pentru a crea un solid de revoluție. Se folosește pentru arbori, butoane, boluri și orice piesă rotundă în jurul unei linii de axă.", + "help.solid.rib": "Adaugă un perete subțire de-a lungul unei curbe de schiță deschise, extins până la fețele următoare ale corpului. Folosiți-o pentru a rigidiza pereți și colțuri, de exemplu în piese matrițate sau imprimate.", "help.solid.scale": "Mărește sau micșorează corpurile selectate cu un factor, la fel în toate direcțiile sau câte unul pentru fiecare axă. Se folosește pentru a redimensiona o formă importată sau pentru a compensa contracția.", "help.solid.shell": "Scobește un solid, lăsând pereți de grosimea stabilită, și poate deschide fețele alese. Se folosește pentru cutii, capace și alte piese cu pereți subțiri.", + "help.solid.sphere": "Creează o sferă după rază sau diametru în jurul unui punct central. Folosiți-o pentru bile, butoane și capete rotunjite.", "help.solid.split_body": "Taie un corp în corpuri separate cu un plan, cu fața altui corp sau cu o linie de schiță. Se folosește pentru piese care sunt imprimate, prelucrate sau colorate separat.", "help.solid.split_face": "Împarte fețele selectate de-a lungul unui plan, al feței altui corp sau al unor curbe de schiță, fără a tăia corpul. Se folosește pentru a da unei părți dintr-o față propria înclinare, culoare sau propriul decalaj.", "help.solid.sweep": "Trage un profil de schiță de-a lungul unui traseu pentru a crea un solid. Se folosește pentru conducte, ghidaje, mânere și orice piesă cu secțiune constantă care urmează o curbă.", + "help.solid.thread": "Adaugă un filet metric ISO pe o față cilindrică, fie ca filet cosmetic, fie tăiat în corp. Folosiți-l pentru șuruburi, găuri filetate și piese imprimate cu filet.", + "help.solid.torus": "Creează un inel după o rază mare în jurul axei lui și o rază mică pentru secțiunea lui rotundă. Folosiți-l pentru garnituri O-ring, mânere și mărgele rotunde.", + "help.solid.web": "Adaugă un perete subțire de-a lungul fiecărei curbe de schiță deschise, fiecare extins până la fețele următoare ale corpului. Folosiți-o pentru o grilă de pereți de susținere în carcase și tăvi.", + "help.surface.blend": "Creează o suprafață care leagă muchiile a două corpuri, tangentă sau cu curbură continuă la ambele. Folosiți-o pentru a uni lin două suprafețe peste un spațiu.", + "help.surface.boundary": "Creează o suprafață care umple o buclă de două până la patru curbe de schiță sau o rețea de curbe. Folosiți-o pentru panouri cu formă liberă definite de muchiile lor.", + "help.surface.extend": "Prelungește o suprafață dincolo de muchiile ei, cu o distanță sau până la un plan ori o față. Folosiți-o pentru ca suprafețele să se suprapună înainte de a le tăia sau coase.", + "help.surface.extrude": "Împinge curbe sau profile de schiță pe o distanță într-o suprafață, doar pereți, fără capace. Folosiți-o pentru pereți subțiri și pentru scule de tăiere sau de împărțire.", + "help.surface.fair": "Netezește interiorul fețelor cu formă liberă, în timp ce muchiile lor rămân pe loc. Folosiți-o pentru a uniformiza denivelările și reflexiile ondulate dintr-o suprafață.", + "help.surface.loft": "Creează o suprafață care trece prin două sau mai multe secțiuni, în ordine, fără capace. Folosiți-l pentru carcase și tranziții între forme diferite.", + "help.surface.match": "Reconstruiește muchia unei suprafețe astfel încât să se întâlnească cu muchia altui corp, cu tangență sau curbură potrivită. Folosiți-o pentru a închide lin o cusătură între două suprafețe.", + "help.surface.offset": "Creează o copie a fețelor la o distanță, de-a lungul normalelor lor, ca nou corp suprafață. Folosiți-o pentru învelișuri, jocuri sau cealaltă parte a unui perete.", + "help.surface.patch": "Umple o regiune de schiță sau o buclă închisă de muchii cu o față, plană sau curbată lin. Folosiți-l pentru a închide găuri într-un corp suprafață.", + "help.surface.reverse_normal": "Întoarce fețele unui corp suprafață, astfel încât exteriorul lor să privească în cealaltă direcție. Folosiți-o înainte de îngroșare sau de coasere, când fețele sunt orientate spre interior.", + "help.surface.revolve": "Rotește curbe sau profile de schiță în jurul unei axe într-o suprafață, fără capace. Folosiți-o pentru carcase rotunde, cum sunt abajururile, vasele și duzele.", + "help.surface.ruled": "Creează o fâșie de linii drepte pornind dintr-un lanț de muchii, pe direcția normalei sau a tangentei fețelor ori sub un unghi. Folosiți-o pentru pereți înclinați, zone de ieșire și suprafețe de separare.", + "help.surface.stitch": "Coase corpurile suprafață între ele de-a lungul muchiilor comune, într-un solid când se închid. Folosiți-o pentru a transforma un set de suprafețe într-un singur corp.", + "help.surface.sweep": "Mută curbe sau profile de schiță de-a lungul unui traseu într-o suprafață, fără capace. Folosiți-o pentru jgheaburi, ornamente și alte forme subțiri care urmează un traseu.", + "help.surface.thicken": "Dă unui corp suprafață o grosime, transformându-l într-un solid. Folosiți-o pentru a face piese imprimabile sau prelucrabile din suprafețe.", + "help.surface.trim": "Taie o suprafață cu un plan, o altă suprafață sau curbe de schiță și păstrează partea pe care o alegeți. Folosiți-o pentru a tăia suprafețele la formă înainte de coasere.", + "help.surface.unstitch": "Separă fețele unui corp în corpuri suprafață proprii; un solid se desface în suprafețe. Folosiți-o pentru a reface fețe individuale ale unui corp.", + "help.surface.untrim": "Elimină găurile dintr-o față sau readuce conturul ei exterior la limitele naturale ale suprafeței. Folosiți-o pentru a recupera suprafața întreagă după o tăiere.", "help.third_party": "Licențe terțe", "help.timeline.compute_all": "Reconstruiește toate operațiile din cronologie, de la început. Se folosește pentru a reîmprospăta modelul și a vedea ce operații eșuează.", "help.timeline.delete": "Șterge operațiile selectate din cronologie; ștergerea unei schițe șterge și operațiile construite din ea. Se folosește pentru a elimina un pas care nu mai este dorit.", + "help.timeline.dependents": "Listează operațiile care ar fi șterse odată cu o operație și pe cele care ar eșua fără ea. Folosiți-o înainte de a șterge sau a modifica o operație.", "help.timeline.play": "Redă operațiile rămase pe rând, cu o pauză de o jumătate de secundă după fiecare reconstrucție. Rulați din nou această comandă sau apăsați Escape pentru a opri; Mergi la sfârșit sare imediat.", + "help.view.apply": "Rotește vizualizarea într-o direcție standard, de exemplu față, sus sau izometrică, sau într-o vizualizare numită salvată. Folosiți-o pentru a privi modelul dintr-un unghi fix.", + "help.view.delete": "Șterge o vizualizare numită salvată. Modelul nu este modificat.", + "help.view.fit": "Mărește și panoramează astfel încât tot modelul sau selecția să umple vizualizarea. Folosiți-o când modelul a ieșit din câmpul vizual.", + "help.view.grid": "Afișează sau ascunde grila din fereastra de vizualizare. Folosiți-o ca ghid de dimensiune și orientare în timpul modelării.", + "help.view.ground_shadow": "Afișează sau ascunde umbra modelului pe sol. Folosiți-o pentru a aprecia mai ușor înălțimea și poziția.", + "help.view.home": "Readuce vizualizarea la direcția de start și încadrează modelul. Folosiți-o pentru a reveni rapid la o vizualizare cunoscută.", + "help.view.list": "Listează vizualizările numite salvate în proiect. Folosiți-o pentru a găsi o vizualizare salvată mai devreme.", + "help.view.navigate": "Rotește, panoramează sau mărește vizualizarea în pași, de exemplu un sfert de rotație la stânga. Folosiți-o pentru a mișca precis camera în loc să trageți.", + "help.view.navigation_bar": "Afișează sau ascunde bara de navigare din partea de jos a ferestrei de vizualizare. Ascundeți-o pentru mai mult spațiu, afișați-o pentru a ajunge cu mouse-ul la instrumentele de vizualizare.", + "help.view.rename": "Dă un nume nou unei vizualizări numite salvate. Vizualizarea în sine rămâne neschimbată.", + "help.view.save": "Salvează poziția curentă a camerei ca vizualizare numită în proiect. Folosiți-o pentru a reveni mai târziu la același unghi, de exemplu pentru capturi de ecran.", + "help.view.spin": "Orbitează continuu vizualizarea în jurul modelului până la următorul clic sau la următoarea tastă. Folosiți-o pentru a prezenta un model din toate părțile.", + "help.view.tool": "Fixează un instrument de cameră, orbitare, panoramare sau zoom, astfel încât tragerea cu butonul stâng al mouse-ului să miște vizualizarea până îl dezactivați. Folosiți-l pe un trackpad sau fără buton de mijloc la mouse.", + "help.view.visual_style": "Schimbă modul în care este desenat modelul: umbrit cu sau fără muchii, cadru de sârmă sau cu muchii ascunse. Folosiți cadrul de sârmă pentru a vedea înăuntru, umbrirea pentru un aspect curat.", "history.sketch_under_constrained": "Constrângeri insuficiente ({count} grade de libertate)", "history.sketch_under_constrained_one": "Constrângeri insuficiente ({count} grad de libertate)", "hole_presets.confirm_delete": "Confirmă ștergerea", @@ -1615,6 +1867,10 @@ "material.selection.mixed": "Materiale diferite", "measure.at": "La", "measure.item": "Element {number}", + "measure.quick_copied": "Măsurătoare copiată", + "measure.quick_copy": "Clic pentru a copia măsurătoarea", + "measure.quick_edges": "{count} muchii", + "measure.quick_faces": "{count} fețe", "messages.ago_h": "acum {n} h", "messages.ago_min": "acum {n} min", "messages.ago_s": "acum {n} s", @@ -1767,6 +2023,11 @@ "not a move": "nu este o mutare", "not a number": "nu este un număr", "open a sketch first": "deschideți mai întâi o schiță", + "parameters.apply_live": "Aplică fiecare modificare la ieșirea din câmpul ei", + "parameters.apply_live_tip": "Dezactivat: modificările așteaptă până la Aplică sau OK, apoi proiectul se reconstruiește o singură dată pentru toate. Anulează sau Escape le abandonează.", + "parameters.held": "{count} modificări în așteptare până la Aplică", + "parameters.held_hint": "Editați expresii și unități. Modificările așteaptă până la Aplică sau OK.", + "parameters.held_one": "1 modificare în așteptare până la Aplică", "parts.automatic_name": "automat", "parts.dimension.across_flats": "Deschidere cheie", "parts.dimension.bore": "Alezaj", @@ -1789,6 +2050,8 @@ "parts.family.set_screw": "Șurub de fixare cu locaș hexagonal, vârf plat", "parts.family.socket_head_cap_screw": "Șurub cu cap cilindric și locaș hexagonal", "parts.family.washer": "Șaibă plată", + "parts.holes": "Găuri (opțional)", + "parts.placement_hint": "alegeți una sau mai multe găuri pentru a așeza câte o piesă în fiecare, sau OK plasează piesa în origine", "pattern.alignment_2": "Aliniere 2", "pattern.alignment_hint": "Numărul include originalul. Centrat echilibrează copiile; Inversare alege partea suplimentară pentru totaluri pare.", "pattern.direction_1": "Direcția 1", @@ -1955,6 +2218,8 @@ "prefs.origin_plane_names": "Numele planelor de origine", "prefs.origin_plane_names_note": "Nume afișate în navigator, în dialoguri și în liste. {top} este XY, {front} este XZ și {right} este YZ, cu Z în sus. Comenzile, scripturile și proiectele salvate păstrează XY, XZ și YZ.", "prefs.overall_speed": "Viteză generală", + "prefs.parameter_list_rows": "Rânduri în lista de parametri", + "prefs.parameter_list_rows_tip": "Săgeata în jos dintr-un câmp de valoare gol listează parametrii, favoriții primii, până la acest număr. Un număr mai mic menține lista ușoară într-un proiect mare.", "prefs.persistent_ribbon_pressed": "Evidențiază comenzile active din panglică", "prefs.persistent_ribbon_pressed_help": "Evidențiază comenzile din panglică cât timp efectele lor sunt active. Dezactivați pentru a păstra aspectul neutru anterior și ajutorul pentru comenzi.", "prefs.perspective_fov": "Câmp vizual", @@ -1984,6 +2249,8 @@ "prefs.quality_fastest": "cel mai rapid", "prefs.quality_slowest": "cel mai lent", "prefs.quality_smoother": "mai neted", + "prefs.quick_measure": "Măsurătoare rapidă în colțul vizualizării", + "prefs.quick_measure_note": "Dimensiunea selecției în colțul din dreapta jos al vizualizării: lungimea unei muchii sau Ø și R pentru una rotundă, aria unei fețe, poziția unui punct, distanța și unghiul dintre două elemente, totalul mai multor muchii sau fețe. Un clic o copiază.", "prefs.render_previous_geometry_budget": "Folosește bugetul anterior de geometrie pentru previzualizarea din Randare", "prefs.render_previous_geometry_budget_help": "Restabilește limita fixă anterioară de geometrie (512 MiB pe desktop pe 64 de biți, 128 MiB în rest) în locul bugetului bazat pe dispozitiv.", "prefs.reports_saved": "{n} salvate", @@ -2029,6 +2296,8 @@ "prefs.sketch_tab_always_note": "Fila Schiță rămâne în panglică, imediat după Model, cât timp nu este deschisă nicio schiță. Sculele ei de desenare pornesc atunci o schiță pe planul sau fața selectată ori pe planul pe care îl alegeți în continuare, cu scula deja activă în ea.", "prefs.sketch_tracking_guides": "Ghiduri de aliniere pentru centru și punct de mijloc", "prefs.sketch_tracking_guides_note": "Treceți cu cursorul peste centrul unui cerc sau peste mijlocul unei linii drepte, apoi deplasați-vă orizontal sau vertical pentru a afișa un ghid punctat temporar.", + "prefs.sketch_tracking_points": "Ghiduri de aliniere de la capete și colțuri în timpul desenării", + "prefs.sketch_tracking_points_note": "Cât timp un instrument de desen așteaptă un punct, trecerea cursorului peste capătul unei linii sau al unui arc, peste un colț de dreptunghi sau peste un punct liber arată același ghid punctat temporar ca la un centru sau un punct de mijloc. Dezactivat: doar centrele și punctele de mijloc.", "prefs.sketching": "Schițare", "prefs.snap_drawing_note": "În timp ce desenați sau trageți un punct de schiță.", "prefs.source": "Sursă", @@ -2056,6 +2325,16 @@ "prefs.theme_follow_off": "Dezactivat", "prefs.theme_follow_stop": "Oprește urmărirea", "prefs.theme_import_hint": "Faceți clic pentru a alege un fișier de temă, trageți unul aici sau apăsați Ctrl+V (Cmd+V pe Mac) cu o temă copiată", + "prefs.theme_option_opacity": "Opacitate", + "prefs.theme_option_opacity_help": "Reglați opacitatea panourilor flotante. Un prag de lizibilitate protejează textul de deasupra culorilor ferestrei de vizualizare.", + "prefs.theme_option_tab_colours": "Culorile filelor", + "prefs.theme_option_tab_colours_help": "Folosește culorile panourilor și ale ferestrei de vizualizare ale acestei teme pentru fiecare filă din panglică.", + "prefs.theme_option_tab_fade_ms": "Tranziția filelor", + "prefs.theme_option_tab_fade_ms_help": "Estompează culorile panourilor și ale ferestrei de vizualizare la schimbarea filelor. Zero comută instantaneu.", + "prefs.theme_options_badge": "Opțiuni", + "prefs.theme_options_more": "Mai multe · {count}", + "prefs.theme_options_reset": "Resetează", + "prefs.theme_options_unavailable": "Indisponibil pe acest dispozitiv", "prefs.theme_paste": "Lipește tema", "prefs.theme_paste_none": "Clipboardul nu conține nicio temă SolidCraft", "prefs.timeline_half_hour": "−30 min", @@ -2077,6 +2356,8 @@ "prefs.under_cursor": "Sub cursor", "prefs.units_precision": "Unități și precizie", "prefs.updates": "Actualizări", + "prefs.value_boxes_in_view": "Arată valorile lângă mânerele lor în vizualizare", + "prefs.value_boxes_in_view_tip": "Valoarea unei comenzi (Distanță, Rază…) într-o casetă lângă mânerul ei, deasupra modelului. Dezactivat: o tastați în fereastra comenzii, care preia tastatura după o alegere, și nimic nu acoperă modelul.", "prefs.view": "Comportamentul vizualizării", "prefs.view_confirm_corner": "OK și Anulează în colțul vizualizării", "prefs.view_confirm_corner_note": "Cât timp este deschis un dialog sau o schiță, o bifă și o cruce în dreapta sus a vizualizării fac ce fac OK și Anulează din dialog, sau finalizează și anulează schița. Controlul de navigare al vizualizării se mută mai jos, sub ele.", @@ -2086,6 +2367,12 @@ "prefs.viewport_background": "Fundal", "prefs.viewport_background_colour": "Culoare", "prefs.viewport_background_theme": "Gradientul temei", + "prefs.viewport_section_status": "Afișează starea secțiunii în fereastra de vizualizare", + "prefs.viewport_section_status_help": "Afișează setarea secțiunii în fereastra de vizualizare. Escape și butonul activ din panglică o dezactivează. Dezactivați pentru a păstra comenzile anterioare ale secțiunii.", + "prefs.viewport_status_details": "Detalii live ale ferestrei de vizualizare", + "prefs.viewport_status_details_help": "Editați setările active de analiză și de secțiune în carduri standard de detalii. Dezactivați pentru a restabili legendele de analiză doar în citire și dialogul Secțiune.", + "prefs.viewport_status_summary": "Restrânge elementele de stare ale ferestrei de vizualizare", + "prefs.viewport_status_summary_help": "Restrânge trei sau mai multe elemente de stare ale ferestrei de vizualizare într-o singură etichetă rezumat. Dezactivați pentru a păstra toate elementele în rândul complet.", "prefs.wheel": "Rotiță", "prefs.wheel_trackpad": "Rotiță și trackpad", "prefs.workspace": "Spațiu de lucru", @@ -2964,7 +3251,9 @@ "render.output_ready": "Gata de randare", "render.output_run_check": "Rulează verificarea", "render.output_size_limit": "{method}: până la {dimension} px pe latură și {pixels} pixeli. Cea mai mare dimensiune care încape: {width} × {height}.", + "render.output_size_method_profile": "Pentru a randa {width} × {height}, alegeți {method} cu profilul de resurse CPU {profile} sau o dimensiune mai mică.", "render.output_size_ok": "Dimensiune OK · {width} × {height} px", + "render.output_size_profile": "Pentru a randa {width} × {height}, alegeți profilul de resurse CPU {profile} sau o dimensiune mai mică.", "render.output_swap_orientation": "Interschimbă lățimea și înălțimea", "render.output_tools_inspector": "Editați dimensiunea și formatul în inspectorul Ieșire.", "render.output_view_image": "Vizualizează imaginea", @@ -3177,6 +3466,7 @@ "render.size_custom": "Personalizat…", "render.size_full_hd": "Full HD", "render.size_hd": "HD", + "render.size_needs_profile": "{size} · necesită {profile}", "render.size_presets": "Presetări de dimensiune", "render.size_presets_pending": "Apare odată cu editorul de ieșire", "render.size_preview": "Previzualizare", @@ -3464,6 +3754,24 @@ "sheet.flange_type_contour": "Contur", "sheet.flange_type_edge": "Muchie", "sheet.no_body": "nu există un corp din tablă: începeți unul cu Flanșă > Bază sau transformați o placă în tablă cu Convertește în tablă (alegeți fața ei mare și plată)", + "sheet.personal_rules.confirm_delete": "Confirmă ștergerea", + "sheet.personal_rules.delete": "Șterge", + "sheet.personal_rules.delete_prompt": "Ștergeți această regulă personală? Regulile deja existente în proiecte rămân neschimbate.", + "sheet.personal_rules.full": "Biblioteca personală conține deja 64 de reguli.", + "sheet.personal_rules.invalid_name": "Introduceți un nume scurt, fără întreruperi de rând sau caractere de control.", + "sheet.personal_rules.invalid_values": "Completați valorile regulii: fiecare trebuie să poată fi calculată în acest proiect, cu o grosime pozitivă și un factor K între 0 și 1.", + "sheet.personal_rules.missing": "Regula personală selectată nu mai este disponibilă.", + "sheet.personal_rules.name": "Numele regulii", + "sheet.personal_rules.name_exists": "Există deja o regulă personală cu acest nume. Folosiți alt nume.", + "sheet.personal_rules.name_hint": "Un nume unic", + "sheet.personal_rules.overwrite": "Salvează modificările în regula personală selectată", + "sheet.personal_rules.rename": "Redenumește", + "sheet.personal_rules.rule": "Regulă personală", + "sheet.personal_rules.save": "Salvează ca nouă", + "sheet.personal_rules.scope": "Salvată în profil, pentru toate proiectele.", + "sheet.personal_rules.select": "Alegeți o regulă", + "sheet.personal_rules.title": "Reguli personale", + "sheet.personal_rules.unsupported": "Această regulă are valori pe care această versiune nu le poate folosi.", "shortcut.json_error": "parametrii trebuie să fie JSON: {error}", "shortcut.no_command": "nu există comanda `{id}`; textul dinaintea JSON trebuie să fie un id de comandă", "shortcut.pin": "Fixează în partea de sus a casetei S", @@ -3500,6 +3808,7 @@ "sketch_text.align_right": "Dreapta", "sketch_text.bold": "Aldin", "sketch_text.builtin_font": "{family} (încorporat)", + "sketch_text.formula": "Începeți cu = pentru a construi cuvintele din parametri: ='W' + number(Width)", "sketch_text.italic": "Cursiv", "sketch_text.loading_fonts": "Se caută fonturile instalate…", "sketch_text.on_path": "Textul urmează traseul său: curba îl poziționează", @@ -3640,6 +3949,8 @@ "ui.3d_mouse_section": "Mouse 3D", "ui.3d_mouse_show_in_view": "Afișează mouse-ul 3D în vizualizarea 3D", "ui.3d_mouse_show_in_view_tip": "O imagine a mouse-ului 3D în colțul din stânga jos, care se mișcă odată cu el.", + "ui.3d_mouse_sketch_orbit": "Rotește schițele cu mouse-ul 3D", + "ui.3d_mouse_sketch_orbit_tip": "Mouse-ul 3D rotește vizualizarea și când o schiță este deschisă și privită frontal, așa cum poate face mouse-ul. Dezactivat: panoramează și mărește schița până când înclinați vizualizarea.", "ui.3d_mouse_skip": "Dispozitivul meu nu poate face asta", "ui.3d_mouse_skip_tip": "Omite această mișcare în ambele sensuri, iar calibrarea o lasă deoparte.", "ui.3d_mouse_speed": "Viteza mouse-ului 3D", @@ -3708,6 +4019,7 @@ "ui.agent_chat_auth_terminal": "Rulați aceasta într-un terminal, apoi apăsați din nou Conectare:", "ui.agent_chat_auth_title": "Autentificare la agent", "ui.agent_chat_cancel": "Anulează", + "ui.agent_chat_cannot_start": "Instalat, dar nu pornește: {error}", "ui.agent_chat_changes": "Modificări: {n}, un singur pas de anulare", "ui.agent_chat_changes_n": "{n} modificări", "ui.agent_chat_changes_of": "{n}/{limit} modificări", @@ -3800,9 +4112,12 @@ "ui.agent_chat_queued_stopped": "În coadă (oprit, netrimis)", "ui.agent_chat_refresh": "Reîmprospătare", "ui.agent_chat_refused": "Nepermis pentru agent: {reason}", + "ui.agent_chat_reinstall": "{agent} este instalat ({program}), dar nu a pornit când SolidCraft l-a verificat: {error}\nInstalarea lui poate fi incompletă sau făcută pentru alt tip de calculator. Instalați-l din nou cu `{install}`, verificați într-un terminal că afișează versiunea, apoi apăsați Reîmprospătare.", "ui.agent_chat_remove": "Elimină din coadă", "ui.agent_chat_remove_file": "Scoate-l din acest mesaj", "ui.agent_chat_remove_imported": "Scoate-l din acest mesaj; piesa rămâne în proiect (Anulare o elimină)", + "ui.agent_chat_reply_cut_off": "Răspunsul a atins limita de lungime înainte de a se termina, deci poate fi incomplet. Cereți agentului să continue sau să dea un răspuns mai scurt.", + "ui.agent_chat_reply_declined": "Modelul a refuzat să răspundă la acest mesaj.", "ui.agent_chat_retry": "Reîncearcă", "ui.agent_chat_right": "Dreapta", "ui.agent_chat_save": "Salvează", @@ -3861,12 +4176,17 @@ "ui.ai_copy_suffix": "copie", "ui.ai_custom_url": "URL personalizat", "ui.ai_default": "Implicit", + "ui.ai_edit": "Editează", + "ui.ai_edit_agent_title": "Editează agentul", + "ui.ai_edit_gone": "Acest agent a fost eliminat, deci nu este nimic de salvat.", + "ui.ai_edit_tip": "Schimbă numele, comanda sau endpointul acestui agent. O cheie salvată rămâne ascunsă.", "ui.ai_http_busy": "endpointul este ocupat sau și-a depășit limita; încercați mai târziu", "ui.ai_http_key": "endpointul a refuzat cheia: verificați-o în Preferințe > AI", "ui.ai_http_not_found": "nu există un astfel de endpoint sau model: verificați URL-ul de bază și numele modelului", "ui.ai_http_too_large": "conversația este prea mare pentru acest endpoint; începeți un chat nou", "ui.ai_key": "Cheie API", "ui.ai_key_hint": "opțional; păstrată în depozitul de chei al sistemului", + "ui.ai_key_keep_hint": "lăsați gol pentru a păstra cheia salvată", "ui.ai_key_needs_https": "O cheie se trimite doar prin HTTPS sau către acest calculator.", "ui.ai_key_note": "Stocată în depozitul de chei al sistemului, niciodată în setările SolidCraft. Lăsați gol pentru un server local.", "ui.ai_keychain_error": "Depozitul de chei al sistemului a refuzat: {error}", @@ -3898,6 +4218,7 @@ "ui.ai_request_timeout": "Timp de expirare a cererii", "ui.ai_request_timeout_hint": "Cât timp poate dura o cerere către model la acest endpoint, de la 30 s la o oră. Măriți-l pentru un model local lent.", "ui.ai_reset": "Resetează", + "ui.ai_save": "Salvează", "ui.ai_settings": "Setări AI…", "ui.ai_show_features": "Afișează funcțiile AI", "ui.ai_show_features_note": "Chatul cu agentul: un panou în care un agent de programare pe care îl folosiți deja sau un endpoint de model ales de dumneavoastră lucrează la proiectul deschis. Dezactivat: fără buton AI, scurtătură, intrare de meniu sau panou, și nu se caută și nu se pornește nimic.", @@ -4071,6 +4392,40 @@ "ui.design_properties.project": "Proiect", "ui.design_properties.revision": "Revizie", "ui.design_saved": "Salvat în {path}", + "ui.details_access_caption": "Fețele arată dacă o sculă le poate atinge din direcția aleasă.", + "ui.details_angle": "Unghi", + "ui.details_continuity_caption": "Editați cusătura selectată. Celelalte cusături își păstrează propriile setări.", + "ui.details_continuity_stale": "Geometrie mai veche: rulați din nou analiza pentru a edita sau a încadra această cusătură.", + "ui.details_copy_list": "Copiază lista", + "ui.details_curvature": "Toleranța curburii", + "ui.details_curvature_caption": "Fețele sunt colorate după raza lor locală de curbură.", + "ui.details_direction": "Direcția sculei", + "ui.details_draft_caption": "Fețele sunt colorate după unghiul lor față de direcția de extragere.", + "ui.details_edit_seam": "Editează cusătura", + "ui.details_fixture": "Exemplar de componentă — doar date de exemplu", + "ui.details_fixture_caption": "Exemplar de componentă cu valori ilustrative; nu este un rezultat al modelului.", + "ui.details_fixture_problem": "Exemplu de constatare cu o problemă", + "ui.details_fixture_readout": "Exemplu de valoare măsurată", + "ui.details_flip": "Răstoarnă", + "ui.details_gap": "Spațiu", + "ui.details_hatch": "Hașurează fețele tăiate", + "ui.details_jump": "Salt de curbură", + "ui.details_key": "Legendă", + "ui.details_offset": "Decalaj", + "ui.details_plane": "Plan", + "ui.details_pull": "Direcția de extragere", + "ui.details_radius": "Rază", + "ui.details_readout": "Citire", + "ui.details_samples": "Eșantioane", + "ui.details_seam": "{body} · Muchia {edge}", + "ui.details_settings": "Setări", + "ui.details_sharp_gap": "Frângere sau spațiu", + "ui.details_show_all": "Afișează toate {count}", + "ui.details_smooth": "Neted", + "ui.details_stripes": "Dungi", + "ui.details_target": "Încadrează geometria", + "ui.details_worst_seams": "Cele mai slabe cusături", + "ui.details_zebra_caption": "Dungile reflectate arată cât de neted se îmbină fețele.", "ui.diameter": "Diametru", "ui.dimension": "Cotă", "ui.direct_interaction": "Interacțiune directă", @@ -4131,6 +4486,7 @@ "ui.equation_y_of_x": "y(x)", "ui.equivalent_radius": "Rază echivalentă", "ui.expand": "Extinde", + "ui.explode_needs_two_components": "Vedere explodată îndepărtează componentele una de alta, deci proiectul trebuie să aibă două sau mai multe (Componentă nouă, Inserează proiect sau Creează componente din corpuri)", "ui.exportDxf": "Exportă DXF…", "ui.export_ellipsis_keymap": "Exportă…", "ui.export_keymap_tip": "Tastele, favoritele casetei S și bara de instrumente, ca fișier", @@ -4419,12 +4775,17 @@ "ui.plugins_skipped": "{count} dosar(e) nu au putut fi citite.", "ui.plugins_version": "{version}", "ui.point": "Punct", + "ui.point_cloud_add_filter": "Adaugă filtru…", "ui.point_cloud_auto_range": "Interval automat", "ui.point_cloud_color_by": "Colorează după", "ui.point_cloud_file_colors": "Culorile fișierului", + "ui.point_cloud_filter_by": "Filtrează după", "ui.point_cloud_height_high": "Cel mai înalt", "ui.point_cloud_height_low": "Cel mai de jos", + "ui.point_cloud_legend_filter": "Afișate și ascunse în legenda de mai sus", "ui.point_cloud_no_file_colors": "Fișierul nu are culori", + "ui.point_cloud_pause": "Pauză", + "ui.point_cloud_play": "Redă", "ui.point_cloud_single_color": "Culoare unică", "ui.point_cloud_size": "Dimensiunea punctelor", "ui.point_cloud_units": "Unitățile fișierului", @@ -4453,6 +4814,7 @@ "ui.properties.inertia_y": "Iyx, Iyy, Iyz", "ui.properties.inertia_z": "Izx, Izy, Izz", "ui.properties.measuring": "Se măsoară…", + "ui.properties.measuring_bodies": "Se măsoară… {done} din {total} corpuri", "ui.properties.measuring_failed": "Măsurarea s-a oprit înainte de a se termina.", "ui.properties.principal_axis": "Axa principală {number}", "ui.properties.principal_moments": "Momente principale", @@ -4520,6 +4882,7 @@ "ui.selectOther": "Selectează altul…", "ui.selectTangentChain": "Selectează lanțul tangent", "ui.select_all": "Selectează tot", + "ui.select_components_by_face": "Selectați componente (o față alege componenta ei)", "ui.selection": "Selecție", "ui.selection_modifiers": "Modificatori de selecție", "ui.selection_shift_add_only": "Shift+clic adaugă fără a elimina", @@ -4561,6 +4924,11 @@ "ui.sketch_intersect_selection_filter": "Filtru de selecție", "ui.sketch_modifiers": "Modificatori de schiță", "ui.sketch_project_geometry": "Geometrie", + "ui.sketch_report.all_constrained": "Totul este complet constrâns.", + "ui.sketch_report.dof": "Grade de libertate", + "ui.sketch_report.loose": "Nu sunt complet constrânse", + "ui.sketch_report.status": "Stare", + "ui.sketch_report.title": "Raportul schiței", "ui.sketch_scale": "Scara schiței", "ui.sketches": "Schițe", "ui.slice": "Felie", @@ -4670,7 +5038,14 @@ "ui.viewport_status_key": "Afișează legenda pentru {name}", "ui.viewport_status_minimum_radius": "Rază minimă", "ui.viewport_status_pairs": "{count} perechi", + "ui.viewport_status_section_hatch": "{setting} · hașură", + "ui.viewport_status_section_plane": "Planul curent", + "ui.viewport_status_section_tilted": "înclinat · {offset}", "ui.viewport_status_stripes": "{count} dungi", + "ui.viewport_status_summary_clear": "Dezactivează tot", + "ui.viewport_status_summary_heading": "În această vizualizare", + "ui.viewport_status_summary_label": "{name} și încă {count} elemente", + "ui.viewport_status_summary_more": "+{count}", "ui.viewport_theme_follow": "Urmează tema", "ui.visual_shaded_hidden_edges": "Umbrit cu muchii ascunse", "ui.visual_shaded_with_edges": "Umbrit cu muchii", diff --git a/assets/locales/ru.json b/assets/locales/ru.json index a121b3934a..dd93d3cd71 100644 --- a/assets/locales/ru.json +++ b/assets/locales/ru.json @@ -60,6 +60,7 @@ "BLEND SURFACE": "СОПРЯГАЮЩАЯ ПОВЕРХНОСТЬ", "BOUNDARY SURFACE": "ГРАНИЧНАЯ ПОВЕРХНОСТЬ", "BROWSER": "БРАУЗЕР", + "Back to the original order": "Вернуть исходный порядок", "Ball": "Шаровое", "Bar": "Полоса", "Base Center": "Центр основания", @@ -593,6 +594,8 @@ "Snap to face and edge centres": "Привязка к центрам граней и рёбер", "SolidCraft cannot submit reports directly. Submit on GitHub; no token is needed in the app.": "SolidCraft не может отправлять отчёты напрямую. Отправьте отчёт на GitHub; токен в приложении не нужен.", "SolidCraft closed before these designs were saved. Their last autosave can be opened.": "SolidCraft закрылся до сохранения этих дизайнов. Можно открыть последнюю автосохранённую версию.", + "Sort A to Z": "Сортировать от А до Я", + "Sort Z to A": "Сортировать от Я до А", "Spacing 2 must be a non-zero number": "Интервал 2 должен быть ненулевым числом", "Spacing must be a non-zero number": "Интервал должен быть ненулевым числом", "Specialise": "Специализировать", @@ -813,6 +816,7 @@ "cmd.appearance_pick_up": "Взять внешний вид", "cmd.appearance_replace": "Заменить внешний вид", "cmd.assembly_bom": "Спецификация", + "cmd.assembly_mass_properties": "Массовые характеристики", "cmd.body.print_face": "Грань печати", "cmd.body_rename": "Переименовать тело", "cmd.browser_collapse_all": "Свернуть всё", @@ -1229,6 +1233,7 @@ "cmd.view.render_browser": "Браузер рендера", "cmd.view.render_dock": "Переместить панель рендера", "cmd.view.render_focus": "Режим фокуса рендера", + "cmd.view.status_details": "Подробности состояния видового экрана", "cmd.view.status_items": "Элементы состояния видового экрана", "cmd.view_ambient_occlusion": "Окружающее затенение", "cmd.view_angle_readout": "Углы вида", @@ -1371,25 +1376,232 @@ "from the rule": "из правила", "help.apache": "Лицензия Apache 2.0", "help.appearance.assign": "Задаёт выбранным телам, граням или компонентам цвет или вид материала, не меняя их вес. Используйте, чтобы различать детали или заранее увидеть отделку.", + "help.assembly.bom": "Перечисляет детали дизайна или компонента с их количеством по номерам деталей и может сохранить список в CSV или JSON. Используйте как список деталей для заказа и сборки.", "help.attributions": "Сведения об авторстве (ATTRIBUTION.md)", "help.build": "Версия {version} · коммит {commit} · сборка от {date}", + "help.canvas.decal": "Помещает изображение на плоскую грань модели, как наклейку. Используйте, чтобы показать на детали надпись, логотип или печатный рисунок.", + "help.canvas.edit": "Меняет подложку: её положение, размер, угол, непрозрачность, отражение, видимость или имя. Используйте, чтобы совместить образец с эскизом, который вы по нему обводите.", + "help.canvas.insert": "Помещает изображение PNG или JPEG на плоскость или грань как полупрозрачный образец. Используйте, чтобы обвести в эскизе фотографию или чертёж, масштабировав изображение до известного размера.", + "help.component.break_link": "Отсоединяет связанный компонент от файла дизайна, оставляя его тела как есть. Используйте, когда деталь больше не должна следовать изменениям исходного файла.", + "help.component.capture_position": "Сохраняет положения деталей, которые вы передвинули вручную, как их новые места. Используйте после перетаскивания деталей на место, если их не удерживает ни одно сопряжение.", + "help.component.create": "Добавляет в дизайн новый пустой компонент: внутрь активного, если вы не выберете другого родителя. Используйте, чтобы дать новой детали сборки собственное место для тел и эскизов.", "help.component.finish_edit": "Сохраняет деталь, которую вы изменяете на месте, возвращается в её сборку и обновляет сборку по ней. Используйте, когда изменения связанной детали готовы.", + "help.component.from_bodies": "Превращает каждое выбранное тело в отдельный компонент с именем этого тела. Используйте, когда дизайн из разрозненных тел должен стать сборкой, детали которой можно соединять и перемещать.", + "help.component.insert_design": "Вставляет другой сохранённый дизайн как компонент, связанный со своим файлом; его твёрдые тела размещаются так же, как в оригинале. Используйте, чтобы повторно применить деталь в сборке и подхватывать последующие изменения командой «Обновить связи».", + "help.component.properties": "Показывает объём, массу, центр масс и моменты инерции компонента по материалам его тел. Используйте, чтобы проверить вес и баланс сборки.", + "help.component.revert_position": "Возвращает вручную перемещённые детали на прежние места, отбрасывая незафиксированные перемещения. Используйте, чтобы отменить пробные перемещения в сборке.", + "help.component.update_links": "Заново читает связанные файлы дизайнов и заменяет изменившиеся тела. Используйте после правки детали, которую применяет эта сборка.", + "help.config.configure": "Добавляет параметры, операции, материалы или внешние виды как столбцы таблицы конфигурации. Используйте, чтобы создавать варианты одного дизайна, например размеры детали.", + "help.config.preview": "Проверяет, что строка таблицы конфигурации пересобирается, не меняя дизайн и его историю. Используйте перед переключением на вариант, в котором вы не уверены.", + "help.config.rules": "Показывает правила таблицы конфигурации, её строки и активную строку. Используйте, чтобы увидеть, как настроены варианты дизайна.", + "help.config.table": "Показывает таблицу конфигурации: строка на вариант, столбец на параметр или операцию, и совпадает ли дизайн с активной строкой. Используйте, чтобы просмотреть варианты дизайна.", "help.connect_note": "Перезапустите каждого ассистента, чтобы загрузить сервер SolidCraft. Перед изменением каждого файла создана резервная копия с расширением .bak.", "help.connect_title": "Подключение ИИ-ассистентов", + "help.construct.axis.edge": "Создаёт вспомогательную ось вдоль прямого ребра или линии эскиза. Используйте как ось для вращения, кругового массива или сопряжения.", + "help.construct.axis.normal_to_face": "Создаёт вспомогательную ось через точку перпендикулярно грани. Используйте как ось, прямо выходящую из поверхности.", + "help.construct.axis.through_cylinder": "Создаёт вспомогательную ось через центр цилиндрической, конической или тороидальной грани либо круглого ребра. Используйте, чтобы вращать элементы или делать массив вокруг круглой детали.", + "help.construct.axis.two_planes": "Создаёт вспомогательную ось в месте пересечения двух плоскостей или плоских граней. Используйте, когда нужная ось — линия между двумя гранями.", + "help.construct.axis.two_points": "Создаёт вспомогательную ось через две точки, вершины или точки эскиза. Используйте для оси, идущей между двумя известными положениями.", + "help.construct.plane.along_path": "Создаёт вспомогательную плоскость, перпендикулярную ребру или кривой, в выбранном месте вдоль неё. Используйте, чтобы нарисовать профиль для операции «Сдвиг» или сечение траектории.", + "help.construct.plane.at_angle": "Создаёт вспомогательную плоскость, повёрнутую на угол от плоскости или грани вокруг оси или ребра. Используйте для наклонных эскизов, например для кронштейна под углом.", + "help.construct.plane.midplane": "Создаёт вспомогательную плоскость посередине между двумя плоскостями или плоскими гранями либо делящую пополам угол между ними. Используйте как плоскость симметрии для зеркального отражения.", + "help.construct.plane.offset": "Создаёт вспомогательную плоскость, параллельную плоскости или плоской грани, на заданном расстоянии или через точку. Используйте, чтобы рисовать на заданной высоте над гранью.", + "help.construct.plane.perpendicular": "Создаёт вспомогательную плоскость, содержащую линию или ребро и перпендикулярную плоскости или грани. Используйте, чтобы рисовать на модели вертикально вдоль ребра.", + "help.construct.plane.tangent": "Создаёт вспомогательную плоскость, касающуюся цилиндра, конуса, сферы или тора в точке. Используйте, чтобы рисовать на боковой стороне круглой детали.", + "help.construct.plane.three_points": "Создаёт вспомогательную плоскость через три точки. Используйте для плоскости, заданной положениями на модели, например тремя углами.", + "help.construct.plane.two_edges": "Создаёт вспомогательную плоскость через два прямых ребра или линии, лежащие в одной плоскости. Используйте для плоскости сквозь деталь, например через два противоположных ребра.", + "help.construct.point.along_path": "Создаёт вспомогательную точку в выбранном месте вдоль ребра или кривой. Используйте как ориентир на полпути вдоль траектории.", + "help.construct.point.center": "Создаёт вспомогательную точку в центре круглого ребра, окружности или дуги эскиза, сферы или тора. Используйте как ориентир для отверстий, осей и сопряжений.", + "help.construct.point.edge_plane": "Создаёт вспомогательную точку там, где ребро или линия пересекает плоскость или плоскую грань. Используйте, чтобы отметить, где траектория пересекает плоскость.", + "help.construct.point.three_planes": "Создаёт вспомогательную точку там, где сходятся три плоскости или плоские грани. Используйте как угловой ориентир, даже если у модели нет вершины.", + "help.construct.point.two_edges": "Создаёт вспомогательную точку там, где два ребра или линии пересекаются или подходят ближе всего друг к другу. Используйте как ориентир в воображаемом углу.", + "help.construct.point.vertex": "Создаёт вспомогательную точку в вершине или точке эскиза. Используйте как ориентир, который остаётся, когда окружающий эскиз или тело скрыты.", + "help.contact.check": "Перечисляет пары деталей в наборах контактов, которые в текущем положении проходят друг сквозь друга. Используйте, чтобы найти столкновения до привода сопряжений или исследования движения.", + "help.contact.create": "Объединяет две или более детали в набор контактов: их тела не проходят друг сквозь друга, пока двигаются сопряжения. Используйте для деталей, которые должны останавливаться при касании, например рычага у упора.", + "help.contact.disable_all": "Отключает контакт, так что приводимые сопряжения и исследования движения больше не останавливаются там, где детали соприкасаются. Используйте, чтобы снова свободно двигать детали или чтобы большая сборка отзывалась быстрее.", + "help.contact.edit": "Меняет набор контактов: входящие в него детали, имя или подавление; набор можно и удалить. Используйте, чтобы изменить, какие детали не должны проходить друг сквозь друга.", + "help.contact.enable_all": "Помещает все детали сборки в один набор контактов, так что никакие две детали не проходят друг сквозь друга, пока двигаются сопряжения. Используйте для быстрой проверки всего механизма.", + "help.contact.enable_sets": "Включает контакт для заданных вами наборов контактов, так что приводимые сопряжения и исследования движения останавливаются там, где их детали прошли бы друг сквозь друга. Используйте, чтобы вернуть свои наборы после отключения контакта.", + "help.contact.list": "Перечисляет наборы контактов дизайна, детали в каждом из них и включён ли контакт. Используйте, чтобы проверить настройку контактов перед приводом сборки.", + "help.document.inspect": "Показывает обзор дизайна: его компоненты, тела, эскизы, операции и параметры. Используйте, чтобы сориентироваться в дизайне, который вы создавали не сами.", + "help.document.units": "Меняет единицы, в которых дизайн показывает длины, например мм или дюймы. Размер геометрии не меняется; подстраиваются только отображение и числа без единиц.", + "help.edit.checkpoint": "Запоминает дизайн, активный эскиз и историю отмен в их текущем состоянии. Используйте перед рискованной попыткой, от которой, возможно, захочется вернуться.", + "help.edit.restore_checkpoint": "Возвращается к контрольной точке вместе с историей отмен. Используйте, чтобы одним шагом отбросить всё сделанное после контрольной точки.", + "help.explode.create": "Создаёт разобранный вид: детали отодвигаются от центра сборки или на заданные вами смещения. Используйте, чтобы показать, как собирается сборка, для инструкций или презентаций.", "help.face.delete": "Удаляет выбранные грани, и соседние грани закрывают промежуток, либо промежуток остаётся открытым как поверхность. Используйте, чтобы убрать скруглённое ребро, отверстие или выступ, которые мешают.", + "help.file.insert_mesh": "Добавляет файл 3MF, STL, OBJ, PLY или glTF в дизайн как тела-меши в новом компоненте. Используйте, чтобы разместить рядом с дизайном скачанную или отсканированную модель как образец.", + "help.file.insert_step": "Добавляет файл STEP, IGES, FreeCAD или Rhino в дизайн как твёрдые тела в новом компоненте; детали сборки становятся вложенными компонентами. Используйте, чтобы подключать модели от поставщиков или из других CAD-программ.", + "help.file.save_mesh": "Сохраняет тела как меш STL, 3MF или OBJ с выбранной гладкостью криволинейных граней. Используйте для подготовки деталей к 3D-печати или для программ, читающих меши.", + "help.file.send_to_slicer": "Сохраняет выбранные тела как файл 3MF и открывает его в выбранном слайсере. Используйте, чтобы перейти от модели к печати одним шагом.", "help.font_details": "Интерфейс использует встроенные шрифты egui (Ubuntu Light и Hack под лицензиями Ubuntu Font Licence и MIT/Bitstream Vera; Noto Emoji и шрифт значков эмодзи под лицензиями OFL и MIT). Для текста эскизов используется Liberation Sans (SIL OFL 1.1). Необязательный пакет шрифтов The Crafting Apps, craft-fonts (BIZ UDPGothic, Shippori Mincho, BIZ UDMincho, Noto Sans CJK SC, Noto Sans Arabic; SIL OFL 1.1), не входит в сборки SolidCraft.", "help.fonts": "Шрифты", + "help.frame.member": "Размещает профиль, например трубу или алюминиевый экструзионный профиль, вдоль линий эскиза рамы, по одному элементу на линию. Используйте для рам, стеллажей и сварных конструкций.", + "help.inspect.accessibility": "Раскрашивает грани, до которых инструмент может дотянуться с одного направления, и те, до которых не может. Используйте, чтобы проверить деталь на обработку или формование с этой стороны.", + "help.inspect.center_of_mass": "Показывает центр масс тел, каждого по отдельности и всех вместе, при равномерной плотности. Используйте, чтобы проверить баланс и понять, куда деталь опрокинется.", + "help.inspect.colored_components": "Задаёт каждому компоненту свой временный цвет, не меняя внешний вид. Используйте, чтобы с первого взгляда различать детали сборки.", + "help.inspect.compare": "Сравнивает два тела и показывает, где у одного есть материал, которого нет у другого, с объёмом и отклонением. Используйте, чтобы сверить изменённую или импортированную деталь с оригиналом.", + "help.inspect.continuity": "Проверяет, насколько плавно грани стыкуются по рёбрам (зазоры, изломы или скачки кривизны), и закрашивает швы. Используйте, чтобы найти места, где поверхности стыкуются неаккуратно.", + "help.inspect.curvature_comb": "Рисует гребёнки вдоль кривых, рёбер или граней: их зубья показывают, насколько сильно они изгибаются. Используйте, чтобы заметить плоские участки, изломы и неравномерную кривизну.", + "help.inspect.curvature_map": "Раскрашивает грани по силе кривизны, красным там, где радиус мал. Используйте, чтобы находить тесные места и оценивать гладкость поверхности.", + "help.inspect.draft": "Раскрашивает грани по углу уклона относительно направления вытягивания и отмечает те, где он слишком мал. Используйте, чтобы проверить деталь перед формованием или литьём.", + "help.inspect.environment_map": "Показывает модель с отражающей, зеркальной отделкой. Используйте, чтобы оценить плавность поверхностей по тому, как по ним бегут отражения.", + "help.inspect.interference": "Находит перекрывающиеся тела и показывает каждое перекрытие с его объёмом. Используйте, чтобы проверить сборку на сталкивающиеся детали.", + "help.inspect.isocurve": "Рисует изопараметрические кривые граней: сетку, на которой построена поверхность. Используйте, чтобы увидеть, как течёт поверхность и где она сгущается.", + "help.inspect.measure": "Измеряет длины, расстояния, углы, площади и радиусы между выбранными элементами либо объём и массу тел. Используйте, чтобы проверить размеры и зазоры.", + "help.inspect.minimum_radius": "Находит наименьший радиус кривизны кривых, рёбер или граней и показывает, где он. Используйте, чтобы убедиться, что инструмент или сопло смогут пройти по форме.", + "help.inspect.section": "Разрезает вид по плоскости и штрихует срезанные грани, не меняя модель. Используйте, чтобы заглянуть внутрь деталей и сборок.", + "help.inspect.zebra": "Показывает чёрно-белые полосы, отражённые на гранях модели. Используйте для оценки гладкости: полосы, которые обрываются или ломаются на ребре, указывают на плохой переход.", + "help.joint.as_built": "Соединяет две детали там, где они уже стоят, не двигая ни одну, и задаёт, как они ещё могут двигаться. Используйте для деталей, которые вы уже поставили в окончательное положение.", + "help.joint.cam": "Связывает вращающийся кулачок с толкателем, так что толкатель скользит или качается, пока под ним проходит контур кулачка. Используйте для кулачков, эксцентриков и приводов клапанов.", + "help.joint.create": "Соединяет две детали в выбранных точках сопряжения и ставит вторую деталь на место; тип сопряжения задаёт, как они ещё могут двигаться. Используйте, чтобы собрать сборку, детали которой движутся как настоящий механизм.", + "help.joint.drive": "Задаёт угол или расстояние сопряжения и двигает вместе с ним детали в пределах ограничений; связанные сопряжения и замкнутые контуры следуют за ним. Используйте, чтобы проверить, что механизм движется как задумано.", + "help.joint.gear": "Связывает два вращающихся сопряжения передаточным отношением, так что поворот одного вращает другое. Используйте для цилиндрических и внутренних зубчатых колёс, задавая отношение по радиусам делительных окружностей или числам зубьев.", + "help.joint.limits": "Задаёт, насколько сопряжение может повернуться или сдвинуться, минимумом и максимумом, либо снимает эти пределы. Используйте, чтобы удерживать шарнир, ползун или рычаг в реальном диапазоне хода.", + "help.joint.motion_link": "Связывает два сопряжения отношением и смещением, так что приведение одного двигает другое. Используйте для фиксированной связи между сопряжениями, которую не охватывают «Зубчатая передача», «Реечная передача» и «Винт».", + "help.joint.origin": "Размещает именованную точку сопряжения на детали: в центре грани или круглого торца, в середине ребра, в углу или в точке. Используйте, чтобы подготовить места крепления сопряжений; точка, выбранная на грани, следует за ней при изменении размера детали.", + "help.joint.rack_pinion": "Связывает вращающуюся шестерню с движущейся рейкой, так что рейка смещается на длину делительной окружности, которую прокатывает шестерня. Используйте для реечных приводов, рулевых механизмов и линейных столов.", + "help.joint.reach": "Перемещает точку детали к цели, меняя сопряжения её цепи в пределах ограничений. Используйте, чтобы ставить механизм в позу по тому, где должен оказаться его конец, например кончик руки робота.", + "help.joint.rigid_group": "Жёстко скрепляет выбранные детали друг с другом как есть, так что они движутся как одно целое. Используйте для деталей, которые составляют единое целое и не должны двигаться друг относительно друга.", + "help.joint.screw": "Связывает вращение и скольжение шагом, так что каждый оборот смещает на это расстояние. Используйте для ходовых винтов, болтов и резьбовых шпинделей.", + "help.library.apply": "Применяет материал или внешний вид из файла локальной библиотеки к телам, граням или компонентам. Используйте, чтобы повторно применять материалы, сохранённые в другом дизайне.", + "help.library.export": "Сохраняет материалы и внешние виды дизайна в файл библиотеки. Используйте, чтобы поделиться ими или применять их в других дизайнах.", + "help.library.inspect": "Проверяет файл библиотеки и перечисляет её материалы и внешние виды, не меняя дизайн. Используйте, чтобы узнать, что содержит библиотека, перед применением.", "help.licences": "Лицензии", "help.material.assign": "Задаёт, из чего сделаны выбранные тела, например сталь, алюминий или ABS. Используйте, чтобы масса и вес соответствовали реальной детали.", + "help.material.properties.validate": "Проверяет карточку данных материала на согласованность чисел и пропущенные значения. Используйте перед тем, как полагаться на эти данные при расчёте массы или жёсткости.", + "help.mesh.convert": "Перестраивает тело-меш, например STL, в редактируемое твёрдое тело с плоскими и цилиндрическими гранями. Используйте, чтобы менять импортированные механические детали как смоделированное тело.", "help.mit": "Лицензия MIT", + "help.motion.study": "Задаёт значения сопряжений по шагам вдоль временной шкалы и воспроизводит их как анимацию. Используйте, чтобы наблюдать движение механизма по последовательности и проверять зазоры по ходу.", + "help.motion.trace": "Прослеживает путь точки детали во время воспроизведения исследования движения или привода сопряжения и после подтверждения сохраняет его как кривую 3D-эскиза. Используйте, чтобы увидеть, куда достаёт механизм, или строить по его траектории.", "help.notice": "NOTICE", + "help.occurrence.ground": "Закрепляет деталь на месте или снова освобождает её, так что сопряжения двигают остальные детали вокруг неё. Используйте для рамы или основания, на которых построена остальная сборка.", "help.occurrence.move": "Перемещает деталь сборки только по степеням свободы, разрешённым её сопряжениями. Выберите грань детали, затем перетащите доступную стрелку или дугу.", "help.parameters.change": "Открывает таблицу именованных значений и выражений, управляющих размерами дизайна. Используйте, чтобы изменить размер в одном месте, а все операции, которые его используют, обновились следом.", + "help.parameters.export": "Сохраняет параметры дизайна как CSV или JSON. Используйте, чтобы править их в электронной таблице или переносить в другой дизайн.", + "help.parameters.graph": "Показывает, какие параметры и операции используют какие другие, а также циклы и ошибки среди них. Используйте, чтобы увидеть, на что повлияет изменение параметра.", + "help.parameters.import": "Читает параметры из CSV или JSON в дизайн: все или ни одного. Используйте, чтобы подтянуть значения из электронной таблицы или другого дизайна.", + "help.parameters.move": "Перемещает пользовательский параметр в списке перед другим параметром или после него. Используйте, чтобы держать связанные параметры рядом.", + "help.parts.fastener": "Добавляет стандартный винт, болт, гайку, шайбу, штифт или подшипник выбранного размера. Поставленный на отверстие, он садится в него через сопряжение, так что крепёж не нужно моделировать самому.", + "help.parts.insert": "Вставляет стандартную деталь выбранного семейства, размера и длины. Поставленная на кромку или стенку отверстия, она садится на это отверстие через жёсткое сопряжение.", + "help.parts.library": "Открывает стандартные детали: винты, болты, гайки, шайбы, установочные винты, цилиндрические штифты и подшипники стандартных размеров. Используйте, чтобы добавить стандартную деталь, а не моделировать её самому.", + "help.parts.resize": "Меняет размер или длину вставленной стандартной детали и перестраивает её; сопряжения удерживают её на месте. Используйте, если винт оказался слишком длинным или отверстию нужен другой размер.", + "help.plastic.assign_rule": "Назначает правило пластика телам, так что их пластиковые операции берут из него уклон и зазор по умолчанию. Используйте, чтобы один раз настроить формованную деталь для всех её операций.", + "help.plastic.boss": "Добавляет бобышку под винт на грани: стойку с необязательным отверстием, уклоном, скруглением у основания и поддерживающими рёбрами жёсткости. Используйте для точек крепления винтов в формованных или печатных корпусах.", + "help.plastic.lip": "Добавляет выступ или ответный паз вдоль кромки тонкостенного тела. Используйте, чтобы две половины корпуса фиксировались друг на друге и плотно закрывались.", + "help.plastic.manage_rules": "Создаёт и изменяет правила пластика: материал, толщину стенки, радиусы, зазор и уклон. Используйте, чтобы операции формованной детали были согласованы.", + "help.plastic.rest": "Добавляет опору на грани: круглую или прямоугольную площадку, сплошную или полую, с уклоном. Используйте как опору или стойку в формованном корпусе.", + "help.plastic.snap_fit": "Добавляет защёлку-крючок на грани с заданными длиной, толщиной и зацепом. Используйте, чтобы скреплять детали без винтов.", + "help.sheet.convert": "Превращает пластину или оболочку одинаковой толщины в тело из листового металла с гибами на выбранных рёбрах. Используйте, чтобы сделать существующую деталь разворачиваемой.", + "help.sheet.export_bend_table": "Сохраняет таблицу гибов тела из листового металла как CSV или JSON: углы, радиусы и положения гибов на развёртке. Используйте для листогибочного пресса или мастерской.", + "help.sheet.export_dxf": "Сохраняет развёртку как DXF с контурами, линиями гибов и необязательными примечаниями о гибах. Используйте для лазерной, плазменной или гидроабразивной резки.", + "help.sheet.flange": "Добавляет фланец: пластину-основание из эскиза, стенку вдоль открытого контура эскиза или отогнутую стенку на рёбрах листа. Используйте для построения деталей из листового металла.", + "help.sheet.flat_pattern": "Создаёт развёртку тела из листового металла с её размером, вырезами и линиями гибов. Используйте, чтобы проверить заготовку перед резкой.", + "help.sheet.fold": "Сгибает лист вдоль линии эскиза на заданный угол с заданным радиусом. Используйте, чтобы согнуть плоский лист после того, как в нём вырезаны элементы.", + "help.sheet.hem": "Загибает кромку листа назад на себя. Используйте для безопасных жёстких кромок без острых заусенцев.", + "help.sheet.manage_rules": "Создаёт и изменяет правила листового металла: толщину, радиус гиба, коэффициент K, разгрузки и зазоры. Используйте, чтобы подогнать их под материал и инструменты вашей мастерской.", + "help.sheet.path_flange": "Добавляет цепочку фланцев вдоль кромки листа по открытой цепочке линий эскиза. Используйте для профилей вроде U- или Z-образного за один шаг.", + "help.sheet.perforate": "Добавляет поле отверстий по всему плоскому листу в шахматном или квадратном порядке. Используйте для вентиляционных отверстий, решёток и облегчённых панелей.", + "help.sheet.refold": "Снова сгибает разогнутое тело из листового металла. Используйте после добавления элементов на плоский лист.", + "help.sheet.unfold": "Разгибает все гибы тела из листового металла. Используйте, чтобы добавить отверстия или вырезы, которые должны пересекать гиб, а затем согните снова.", + "help.sketch.arc.center_point": "Рисует дугу окружности вокруг центральной точки от начальной точки до конечной. Используйте, когда центр дуги известен, например для радиуса вокруг отверстия или оси.", + "help.sketch.arc.tangent": "Рисует дугу, которая начинается в конце линии или дуги и продолжается по касательной к ней. Используйте, чтобы продолжить цепочку плавным изгибом вместо угла.", + "help.sketch.arc.three_point": "Рисует дугу от начальной точки до конечной через третью точку. Используйте, когда дуга должна проходить через заданные точки, а не вокруг известного центра.", + "help.sketch.arc3d": "Рисует точную дугу окружности в пространстве через три точки при включённом 3D-эскизе. Используйте для изгибов траекторий и направляющих, выходящих из плоскости эскиза.", + "help.sketch.auto_constrain": "Добавляет ограничения, которым эскиз уже почти соответствует (горизонтальность, параллельность, совпадение), а затем проставляет размеры, пока он не станет полностью определён. Используйте, чтобы зафиксировать грубо нарисованный эскиз за один шаг.", + "help.sketch.auto_constrain_from_datum": "Работает как «Автоограничения», но отсчитывает положения от выбранной базовой точки, а не от начала координат. Используйте, когда деталь размечена от одной опорной точки, например угла или отверстия.", + "help.sketch.blend_curve": "Соединяет концы двух кривых плавным сплайном, касательным к обеим, а при желании и с непрерывной кривизной. Используйте, чтобы перекрыть зазор между кривыми без излома.", + "help.sketch.break": "Разделяет кривую на части там, где её пересекают другие кривые, или в точках, которые вы на ней поставите. Используйте, когда часть кривой должна получить собственное ограничение или быть удалена отдельно.", + "help.sketch.cancel": "Выходит из эскиза и отбрасывает всё, что изменено с момента его создания или открытия для правки. Отмена последнего действия вернёт изменения.", + "help.sketch.centerline": "Переключает линии между обычными и осевыми: осями, которые профили не учитывают. Используйте осевую линию как ось для «Вращения», зеркала или ограничения симметрии.", + "help.sketch.chamfer.distance_angle": "Снимает фаску на углу между двумя линиями по расстоянию вдоль первой линии и углу. Используйте, когда на чертеже фаска задана длиной и углом, например 2 мм × 30°.", + "help.sketch.chamfer.equal_distance": "Снимает фаску на углу между двумя линиями на одинаковое расстояние по обеим. Используйте для обычной фаски 45° на контуре.", + "help.sketch.chamfer.two_distance": "Снимает фаску на углу между двумя линиями с отдельным расстоянием вдоль каждой линии. Используйте для фасок не под 45°, заданных двумя длинами.", + "help.sketch.circle.center": "Рисует окружность по центру и диаметру. Используйте для отверстий, бобышек и других круглых элементов с известным центром.", + "help.sketch.circle.three_point": "Рисует окружность через три точки. Используйте, когда окружность должна пройти через заданные точки, например существующей геометрии.", + "help.sketch.circle.three_tangent": "Рисует окружность, касающуюся трёх линий, окружностей или дуг. Используйте, чтобы вписать окружность в пространство между тремя кривыми, например вписанную окружность треугольника.", + "help.sketch.circle.two_point": "Рисует окружность по двум концам её диаметра. Используйте, когда известны точки на краю окружности, а не её центр.", + "help.sketch.circle.two_tangent": "Рисует окружность заданного размера, касающуюся двух линий, окружностей или дуг. Используйте для скругления фиксированного радиуса между двумя кривыми.", + "help.sketch.conic": "Рисует коническую кривую между двумя конечными точками; её форму задают вершина и значение rho. Используйте для плавных переходов, наполненность которых нужно подстроить: от плоской до почти острого угла.", + "help.sketch.constrainer": "Применяет ограничение, которому одна или две выбранные кривые уже почти соответствуют (горизонтальность, параллельность, касание или равенство). Используйте, чтобы привести в порядок нарисованный от руки эскиз по одному ограничению за раз.", + "help.sketch.constraint.coincident": "Помещает точку на другую точку, кривую или ось эскиза. Используйте, чтобы закрыть зазоры в профиле или поставить точку на линию.", + "help.sketch.constraint.collinear": "Располагает две линии на одной прямой. Используйте, чтобы выровнять прерванные рёбра, например обе стороны выемки.", + "help.sketch.constraint.concentric": "Задаёт двум окружностям или дугам общий центр. Используйте для колец, цековок и валов с уступами.", + "help.sketch.constraint.curvature": "Заставляет две кривые, встречающиеся в конечной точке, продолжаться с одинаковой кривизной (G2). Используйте там, где касательного стыка недостаточно для плавности, например на видимых поверхностях.", + "help.sketch.constraint.equal": "Делает линии одинаковой длины, а окружности и дуги — одинакового радиуса. Используйте, чтобы один размер управлял несколькими одинаковыми элементами.", + "help.sketch.constraint.fix": "Фиксирует точки или кривые на месте или снова освобождает их. Используйте для геометрии, которая не должна двигаться при перетаскивании или решении эскиза.", + "help.sketch.constraint.horizontal_vertical": "Делает линию, или линию между двумя точками, горизонтальной или вертикальной, в зависимости от того, что ближе. Используйте, чтобы выровнять контур по осям эскиза.", + "help.sketch.constraint.midpoint": "Ставит точку на середину линии или совмещает середины двух линий. Используйте, чтобы центрировать геометрию на ребре.", + "help.sketch.constraint.parallel": "Делает две линии параллельными. Используйте для сторон, которые должны оставаться параллельными при изменении других размеров.", + "help.sketch.constraint.perpendicular": "Делает так, чтобы две линии встречались под прямым углом. Используйте для прямых углов между линиями, не лежащими на осях эскиза.", + "help.sketch.constraint.polygon": "Превращает замкнутую цепочку линий в правильный многоугольник: равные стороны, все углы на одной окружности. Используйте, чтобы сделать нарисованный от руки многоугольник правильным и сохранить это.", + "help.sketch.constraint.symmetry": "Делает две точки или кривые зеркальными друг для друга относительно линии или центральной точки. Используйте, чтобы профиль оставался симметричным при изменении одной стороны.", + "help.sketch.constraint.tangent": "Заставляет две кривые касаться, например линию и дугу. Используйте для плавных стыков без излома.", + "help.sketch.construction": "Переключает кривые между обычной и вспомогательной геометрией, которую профили не учитывают. Используйте вспомогательные кривые как помощники для разметки, ориентиры и размеры.", + "help.sketch.copy": "Копирует выбранные кривые и точки эскиза вместе с ограничениями и размерами между ними. Вставьте их в этот эскиз или в другой.", + "help.sketch.create": "Начинает новый 2D-эскиз на плоскости или плоской грани модели. Каждое «Выдавливание», «Вращение» и «Сдвиг» начинается с эскиза.", + "help.sketch.cut": "Копирует выбранную геометрию эскиза и удаляет её из эскиза одним шагом отмены. Вставьте её в другое место этого эскиза или в другой эскиз.", + "help.sketch.dimension": "Добавляет управляющий размер: длину, расстояние, угол, радиус или диаметр, в зависимости от выбранного. Введите значение или имя параметра, и эскиз последует за ним.", + "help.sketch.edit": "Открывает существующий эскиз для правки. Операции, построенные на нём, обновятся, когда вы завершите эскиз.", + "help.sketch.ellipse": "Рисует эллипс по центру, концу большой оси и точке на малой стороне. Используйте для овальных проёмов, кулачков и других вытянутых круглых форм.", "help.sketch.equation_curve": "Рисует кривую по формулам: x(t) и y(t) либо y = f(x) на заданном интервале. В формулах можно использовать параметры, и кривая следует за ними. Используйте для синусоиды, спирали или эвольвенты зуба шестерни.", + "help.sketch.extend": "Удлиняет линию или дугу до ближайшей кривой на её пути либо наращивает две кривые, пока они не встретятся. Используйте, чтобы закрыть зазоры в профиле после обрезки или правки.", + "help.sketch.fillet": "Скругляет угол, где встречаются две линии или дуги, дугой заданного радиуса. Несколько углов, скруглённых вместе, получают один радиус.", + "help.sketch.finish": "Выходит из эскиза и возвращается к модели. Его замкнутые профили становятся готовы для «Выдавливания», «Вращения» и других операций.", + "help.sketch.finish_auto_constrain": "Запускает «Автоограничения» для эскиза, затем завершает его. Используйте, чтобы выйти из эскиза полностью определённым за один шаг.", + "help.sketch.fit_curves_to_section": "Разрезает меш или твёрдое тело плоскостью эскиза и подбирает линии и дуги по сечению. Используйте, чтобы воссоздать отсканированную или импортированную деталь как редактируемый эскиз.", + "help.sketch.include_3d": "Переносит рёбра модели, вершины и кривые других эскизов в этот эскиз как связанные ссылки, проецируя на его плоскость те, что лежат вне её. Используйте, чтобы строить по геометрии, не лежащей в плоскости эскиза.", + "help.sketch.infinite": "Переключает линии между обычными и бесконечными вспомогательными, идущими через весь вид. Используйте их как ориентиры разметки, которые никогда не оказываются слишком короткими.", + "help.sketch.insert_dxf": "Читает кривые чертежа DXF в эскиз, масштабируя, поворачивая и размещая их так, как вы зададите. Используйте, чтобы строить по 2D-чертежу из другой программы, например по контуру для лазерной резки.", + "help.sketch.insert_svg": "Читает контуры рисунка SVG в эскиз, масштабируя, поворачивая и размещая их так, как вы зададите. Используйте, чтобы превратить логотип или векторный контур в кривые, которые можно выдавить.", + "help.sketch.inspect": "Открывает отчёт об открытом эскизе: сколько осталось степеней свободы и определён ли он полностью. Используйте, чтобы выяснить, что ещё двигается, прежде чем завершить эскиз.", + "help.sketch.intersect": "Добавляет кривые или точки, где тела, грани, рёбра или другие эскизы пересекают плоскость эскиза. Они остаются связанными и следуют за моделью при её изменении.", + "help.sketch.intersection_curve": "Создаёт 3D-кривые там, где встречаются два тела или две грани, связанные с ними. Используйте как траектории или направляющие, следующие за моделью.", + "help.sketch.isoparametric_curve": "Создаёт 3D-кривую вдоль направления U или V грани через выбранную точку, связанную с гранью. Используйте как траекторию или линию разделения, следующую за поверхностью.", + "help.sketch.line": "Рисует соединённые линии точка за точкой, как открытую цепочку или замкнутый контур. Это основной инструмент для большинства профилей.", + "help.sketch.line.midpoint": "Рисует линию от её середины до одного конца, второй конец отражается зеркально. Используйте для линий с центром в точке, например симметричного ребра.", + "help.sketch.line3d": "Рисует соединённые прямые линии между точками в пространстве при включённом 3D-эскизе. Используйте для траекторий и рам, выходящих из плоскости эскиза.", + "help.sketch.mirror": "Отражает кривые и точки эскиза относительно линии или оси и добавляет ограничения симметрии. Используйте, чтобы нарисовать половину симметричного профиля и достроить его.", + "help.sketch.move": "Перемещает или поворачивает кривые и точки эскиза либо их копию. Используйте, чтобы переставить геометрию, сохранив её форму и ограничения.", + "help.sketch.offset": "Создаёт копию цепочки кривых на заданном расстоянии с одной или с обеих сторон. Используйте для стенок, контуров и зазоров; расстояние остаётся редактируемым размером.", + "help.sketch.paste": "Вставляет скопированную или вырезанную геометрию эскиза в активный эскиз. Поместите её в точку или сместите на заданную величину.", + "help.sketch.pattern.circular": "Копирует геометрию эскиза вокруг центральной точки на полный круг или заданный угол. Используйте для окружностей болтов, спиц и зубьев.", + "help.sketch.pattern.rectangular": "Копирует геометрию эскиза рядами и столбцами с заданным шагом. Используйте для сеток отверстий, пазов или вентиляционных прорезей.", + "help.sketch.point": "Ставит точку эскиза. Используйте как ориентир для размеров, ограничений, центров отверстий или последующих операций.", + "help.sketch.point3d": "Ставит точку в пространстве при включённом 3D-эскизе. Используйте как ориентир для 3D-кривых и траекторий.", + "help.sketch.polygon.circumscribed": "Рисует правильный многоугольник вокруг окружности; размер задаётся от центра до середины стороны. Используйте, когда известен размер под ключ, например у шестигранной гайки.", + "help.sketch.polygon.edge": "Рисует правильный многоугольник по двум концам одной стороны. Используйте, когда известны длина и положение стороны.", + "help.sketch.polygon.inscribed": "Рисует правильный многоугольник внутри окружности; размер задаётся от центра до угла. Используйте, когда известен размер между углами.", + "help.sketch.project": "Переносит рёбра, грани, вершины модели и кривые других эскизов в этот эскиз, проецируя их на его плоскость. Они остаются связанными и следуют за моделью при её изменении.", + "help.sketch.project_to_surface": "Проецирует кривые других эскизов по нормали этого эскиза на грань как связанные 3D-кривые. Используйте, чтобы нанести логотип, линию разделения или траекторию на изогнутую поверхность.", + "help.sketch.rectangle.center": "Рисует прямоугольник по центру и одному углу. Используйте для контуров с центром в точке, например в начале координат.", + "help.sketch.rectangle.rounded": "Рисует прямоугольник со всеми четырьмя углами, скруглёнными одним радиусом. Используйте для пластин, крышек и проёмов со скруглёнными углами.", + "help.sketch.rectangle.three_point": "Рисует прямоугольник по одной стороне и точке на противоположной. Используйте для прямоугольников под углом к осям эскиза.", + "help.sketch.rectangle.two_point": "Рисует прямоугольник по двум противоположным углам. Используйте для контуров пластин, блоков и карманов.", + "help.sketch.scale": "Масштабирует кривые и точки эскиза на коэффициент относительно базовой точки. Используйте, чтобы изменить размер части эскиза, не рисуя её заново.", + "help.sketch.slot.arc_center": "Рисует изогнутый паз заданной ширины вдоль дуги, заданной центром, началом и концом. Используйте для регулировочных пазов вокруг оси.", + "help.sketch.slot.arc_three_point": "Рисует изогнутый паз заданной ширины вдоль дуги через три точки. Используйте, когда траектория паза должна пройти через заданные точки.", + "help.sketch.slot.center_point": "Рисует прямой паз заданной ширины от его центра до центра одной торцевой дуги. Используйте для пазов с центром в точке.", + "help.sketch.slot.center_to_center": "Рисует прямой паз заданной ширины между центрами двух торцевых дуг. Используйте для пазов, размеры которых заданы от центра до центра, например регулировочных пазов под винты.", + "help.sketch.slot.overall": "Рисует прямой паз заданной ширины от одного внешнего конца до другого. Используйте, когда важна полная длина паза.", + "help.sketch.spline.control_point": "Рисует кубический сплайн по контрольным точкам, через которые он не проходит. Используйте для плавных текучих кривых, форму которых вы задаёте перемещением точек.", + "help.sketch.spline.control_point_5": "Рисует сплайн степени 5 по контрольным точкам. Используйте там, где кривые должны стыковаться с более плавной кривизной, например на видимых стилизованных поверхностях.", + "help.sketch.spline.fit_point": "Рисует плавный сплайн, проходящий через каждую поставленную вами точку, открытый или замкнутый. Используйте, чтобы обвести форму по известным точкам.", + "help.sketch.spline3d": "Рисует плавную кривую в пространстве через точки при включённом 3D-эскизе. Используйте для траекторий сдвига и трасс кабелей, выходящих из плоскости эскиза.", + "help.sketch.spun_profile": "Создаёт в плоскости эскиза контур, который тело заметает, вращаясь вокруг оси. Используйте, чтобы получить силуэт точёной детали для «Вращения» или чертежа.", + "help.sketch.text": "Пишет в эскизе текст выбранным шрифтом, высотой и под выбранным углом; слова могут следовать за параметрами. Выдавите его или нанесите «Тиснением» для надписей и маркировки.", + "help.sketch.transform": "Масштабирует, поворачивает и перемещает геометрию эскиза относительно центральной точки за один шаг. Используйте, чтобы сразу переставить и изменить размер выбранного.", + "help.sketch.trim": "Удаляет участок кривой между ближайшими пересечениями вокруг места щелчка. Используйте, чтобы превратить перекрывающиеся линии в замкнутый профиль.", "help.solid.align": "Перемещает выбранные тела так, чтобы точка, грань или плоскость на них совпала с точкой, гранью или плоскостью в другом месте. Используйте, чтобы приставить деталь к другой, не измеряя смещение.", + "help.solid.boundary_fill": "Создаёт тела из областей, ограниченных перекрывающимися телами-инструментами, заполняя только выбранные области. Используйте, чтобы построить форму из пространства, которое совместно ограничивают несколько тел.", + "help.solid.bounding_solid": "Создаёт бокс, охватывающий выбранные тела, с необязательным запасом. Используйте, чтобы увидеть габариты детали или как заготовку, из которой её обрабатывают.", + "help.solid.box": "Создаёт бокс по длине, ширине и высоте от угловой точки или с центром в точке. Используйте для быстрого блока, заглушки или тела, в котором вырезают элементы.", "help.solid.chamfer": "Срезает выбранные рёбра плоской фаской на заданное расстояние. Используйте, чтобы притупить острые кромки или облегчить вход штифта или винта.", + "help.solid.coil": "Создаёт винтовое тело, например пружину, по диаметру, числу витков, высоте или шагу и круглому либо квадратному сечению. Используйте для пружин, спиралей и винтовых гребней.", "help.solid.combine": "Объединяет тела, вырезает их из целевого тела или оставляет только общий для них объём; тела-инструменты можно сохранить. Используйте, чтобы слить детали, вырезать одной формой другую или найти, где два тела пересекаются.", + "help.solid.cone": "Создаёт конус по окружности основания и высоте или усечённый конус, если у верхней части тоже есть радиус. Используйте для сужений, воронок, зенковок и острий.", + "help.solid.cylinder": "Создаёт цилиндр по радиусу или диаметру и высоте, стоящий на точке вдоль оси. Используйте для валов, штифтов, бобышек и круглых заготовок.", "help.solid.draft": "Наклоняет выбранные грани на заданный угол, отсчитываемый от нейтральной плоскости. Используйте для литых и формованных деталей, чтобы они свободно извлекались из формы.", + "help.solid.emboss": "Выпячивает профили эскиза, например текст или логотип, из грани или вдавливает их в неё. Используйте для надписей, табличек и маркировки на плоских и круглых гранях.", "help.solid.extrude": "Выдавливает профиль эскиза в 3D на заданное расстояние, добавляя или удаляя материал. Используйте, чтобы превратить плоский контур в брусок, выступ или карман.", "help.solid.fillet": "Скругляет выбранные рёбра плавным круговым переходом заданного радиуса. Используйте, чтобы смягчить острые рёбра ради прочности, удобства или внешнего вида.", "help.solid.hole": "Сверлит круглое отверстие заданного диаметра в выбранной точке грани. Используйте для отверстий под винты и штифты и других простых круглых проёмов.", @@ -1398,20 +1610,60 @@ "help.solid.move": "Перемещает или поворачивает выбранные тела либо их копию на заданное расстояние и угол. Используйте, чтобы переставить тело или создать второе в другом месте.", "help.solid.offset_face": "Смещает выбранные грани внутрь или наружу на заданное расстояние, а окружающие грани следуют за ними. Используйте, чтобы сделать стенку толще, карман глубже или выступ выше, не редактируя эскиз.", "help.solid.pattern.circular": "Повторяет выбранные объекты вокруг оси с заданным количеством и общим углом. Используйте для болтов по окружности, спиц, рёбер и зубьев.", + "help.solid.pattern.path": "Копирует операции или тела вдоль траектории эскиза по числу и шагу. Используйте для отверстий вдоль изогнутого ребра или деталей вдоль направляющей.", "help.solid.pattern.rectangular": "Повторяет выбранные объекты в одном или двух направлениях с заданным количеством и шагом. Используйте для рядов и сеток отверстий, рёбер жёсткости, штифтов или ножек.", + "help.solid.pipe": "Создаёт круглую трубу вдоль траектории из кривых эскиза, сплошную или полую с заданной толщиной стенки. Используйте для труб, проводов, кабелей и направляющих.", "help.solid.press_pull": "Добавляет или удаляет материал при перетаскивании грани либо скругляет рёбра при их перетаскивании. Используйте для быстрых изменений прямо на модели.", "help.solid.remove": "Убирает выбранные тела из дизайна начиная с этого места в истории; предыдущие шаги их сохраняют. Используйте, чтобы убрать вспомогательное тело, например режущий инструмент, который больше не нужен.", "help.solid.replace_face": "Переносит выбранные плоские грани на плоскость или на параллельную грань, и тело растёт или уменьшается, чтобы достичь её. Используйте, чтобы совместить грань вровень с другой деталью или вспомогательной плоскостью.", "help.solid.revolve": "Вращает профиль эскиза вокруг оси, создавая тело вращения. Используйте для валов, ручек, чаш и любых деталей, круглых относительно осевой линии.", + "help.solid.rib": "Добавляет ребро жёсткости: тонкую стенку вдоль открытой кривой эскиза, доведённую до ближайших граней тела. Используйте, чтобы усилить стенки и углы, например в формованных или печатных деталях.", "help.solid.scale": "Увеличивает или уменьшает выбранные тела на коэффициент, одинаковый по всем направлениям или свой для каждой оси. Используйте, чтобы изменить размер импортированной формы или учесть усадку.", "help.solid.shell": "Делает тело полым, оставляя стенки заданной толщины, и может открывать выбранные грани. Используйте для коробок, крышек и других тонкостенных деталей.", + "help.solid.sphere": "Создаёт сферу по радиусу или диаметру вокруг центральной точки. Используйте для шаров, ручек и скруглённых концов.", "help.solid.split_body": "Разрезает тело на отдельные тела плоскостью, гранью другого тела или линией эскиза. Используйте, чтобы получить детали, которые печатаются, обрабатываются или окрашиваются отдельно.", "help.solid.split_face": "Делит выбранные грани по плоскости, грани другого тела или кривым эскиза, не разрезая тело. Используйте, чтобы задать части грани собственный уклон, цвет или смещение.", "help.solid.sweep": "Протягивает профиль эскиза вдоль траектории, создавая тело. Используйте для труб, направляющих, ручек и любых деталей постоянного сечения, следующих по кривой.", + "help.solid.thread": "Добавляет метрическую резьбу ISO на цилиндрическую грань: условную или вырезанную в теле. Используйте для болтов, резьбовых отверстий и печатных винтовых деталей.", + "help.solid.torus": "Создаёт кольцо по большому радиусу вокруг его оси и малому радиусу круглого сечения. Используйте для уплотнительных колец, ручек и круглых бусин.", + "help.solid.web": "Добавляет тонкую перегородку вдоль каждой открытой кривой эскиза, каждую доведённую до ближайших граней тела. Используйте для сетки поддерживающих перегородок внутри корпусов и лотков.", + "help.surface.blend": "Создаёт поверхность, соединяющую рёбра двух тел, касательную или с непрерывной кривизной к обоим. Используйте, чтобы плавно соединить две поверхности через зазор.", + "help.surface.boundary": "Создаёт поверхность, заполняющую контур из двух–четырёх кривых эскиза, или сеть кривых. Используйте для свободных панелей, заданных своими краями.", + "help.surface.extend": "Удлиняет поверхность за её кромки на расстояние или до плоскости или грани. Используйте, чтобы поверхности перекрывались перед обрезкой или сшиванием.", + "help.surface.extrude": "Выдавливает кривые или профили эскиза на расстояние в поверхность: только стенки, без торцов. Используйте для тонких стенок и режущих или разделяющих инструментов.", + "help.surface.fair": "Сглаживает внутреннюю часть свободных граней, пока их кромки остаются на месте. Используйте, чтобы выровнять бугры и волнистые отражения на поверхности.", + "help.surface.loft": "Создаёт поверхность, проходящую по порядку через два или более сечений, без торцов. Используйте для оболочек и переходов между разными формами.", + "help.surface.match": "Перестраивает кромку поверхности так, чтобы она стыковалась с кромкой другого тела, с согласованными касательными или кривизной. Используйте, чтобы плавно закрыть шов между двумя поверхностями.", + "help.surface.offset": "Создаёт копию граней на расстоянии по их нормалям как новое тело-поверхность. Используйте для оболочек, зазоров или другой стороны стенки.", + "help.surface.patch": "Заполняет область эскиза или замкнутый контур рёбер гранью, плоской или плавно изогнутой. Используйте, чтобы закрывать отверстия в теле-поверхности.", + "help.surface.reverse_normal": "Переворачивает грани тела-поверхности, так что их внешняя сторона смотрит в другую сторону. Используйте перед утолщением или сшиванием, если грани направлены внутрь.", + "help.surface.revolve": "Вращает кривые или профили эскиза вокруг оси в поверхность, без торцов. Используйте для круглых оболочек, таких как абажуры, чаши и сопла.", + "help.surface.ruled": "Создаёт полосу из прямых линий от цепочки рёбер вдоль нормали грани, по касательной или под углом. Используйте для стенок с уклоном, переходных участков и поверхностей разъёма.", + "help.surface.stitch": "Сшивает тела-поверхности по общим рёбрам, а если они замыкаются, получается твёрдое тело. Используйте, чтобы превратить набор поверхностей в одно тело.", + "help.surface.sweep": "Протягивает кривые или профили эскиза вдоль траектории в поверхность, без торцов. Используйте для желобов, накладок и других тонких форм, следующих за траекторией.", + "help.surface.thicken": "Придаёт телу-поверхности толщину, превращая его в твёрдое тело. Используйте, чтобы получать из поверхностей детали для печати или обработки.", + "help.surface.trim": "Обрезает поверхность плоскостью, другой поверхностью или кривыми эскиза и оставляет выбранную сторону. Используйте, чтобы подрезать поверхности по форме перед сшиванием.", + "help.surface.unstitch": "Разделяет грани тела на отдельные тела-поверхности; твёрдое тело распадается на поверхности. Используйте, чтобы переработать отдельные грани тела.", + "help.surface.untrim": "Удаляет отверстия из грани или возвращает её внешнюю границу к естественным границам поверхности. Используйте, чтобы вернуть полную поверхность после обрезки.", "help.third_party": "Лицензии сторонних компонентов", "help.timeline.compute_all": "Перестраивает все операции в истории с самого начала. Используйте, чтобы обновить модель и увидеть, какие операции завершаются с ошибкой.", "help.timeline.delete": "Удаляет выбранные операции из истории; при удалении эскиза также удаляются операции, построенные на нём. Используйте, чтобы убрать шаг, который больше не нужен.", + "help.timeline.dependents": "Перечисляет операции, которые будут удалены вместе с выбранной, и те, что перестанут работать без неё. Используйте перед удалением или изменением операции.", "help.timeline.play": "Воспроизводит оставшиеся операции по одной с паузой в полсекунды после каждой перестройки. Чтобы остановить, запустите эту команду снова или нажмите Esc; «Перейти в конец» переходит мгновенно.", + "help.view.apply": "Поворачивает вид к стандартному направлению, например спереди, сверху или изометрии, либо к сохранённому именованному виду. Используйте, чтобы смотреть на модель под фиксированным углом.", + "help.view.delete": "Удаляет сохранённый именованный вид. Модель не меняется.", + "help.view.fit": "Масштабирует и сдвигает вид так, чтобы вся модель или выделенное заполнили его. Используйте, когда модель ушла из поля зрения.", + "help.view.grid": "Показывает или скрывает сетку на видовом экране. Используйте как ориентир размера и направления при моделировании.", + "help.view.ground_shadow": "Показывает или скрывает тень модели на земле. Используйте, чтобы легче оценивать высоту и положение.", + "help.view.home": "Возвращает вид в главное направление и вписывает модель. Используйте, чтобы быстро вернуться к известному виду.", + "help.view.list": "Перечисляет именованные виды, сохранённые в дизайне. Используйте, чтобы найти вид, сохранённый ранее.", + "help.view.navigate": "Поворачивает, сдвигает или масштабирует вид шагами, например четверть оборота влево. Используйте, чтобы точно двигать камеру вместо перетаскивания.", + "help.view.navigation_bar": "Показывает или скрывает панель навигации внизу видового экрана. Скройте её, чтобы освободить место, и покажите, чтобы пользоваться инструментами вида мышью.", + "help.view.rename": "Даёт сохранённому именованному виду новое имя. Сам вид не меняется.", + "help.view.save": "Сохраняет текущее положение камеры как именованный вид в дизайне. Используйте, чтобы позже вернуться к тому же ракурсу, например для снимков экрана.", + "help.view.spin": "Непрерывно вращает вид вокруг модели до следующего щелчка или нажатия клавиши. Используйте, чтобы показать модель со всех сторон.", + "help.view.tool": "Закрепляет инструмент камеры (орбиту, панорамирование или масштаб), так что перетаскивание левой кнопкой мыши двигает вид, пока вы его не отключите. Используйте на трекпаде или без средней кнопки мыши.", + "help.view.visual_style": "Переключает способ отрисовки модели: тонированная с рёбрами или без них, каркас или со скрытыми рёбрами. Используйте каркас, чтобы заглянуть внутрь, а тонированный вид — для чистого вида.", "hole_presets.confirm_delete": "Подтвердить удаление", "hole_presets.delete": "Удалить", "hole_presets.delete_prompt": "Удалить этот личный пресет? Существующие отверстия не изменятся.", @@ -1586,6 +1838,10 @@ "material.selection.mixed": "Разные материалы", "measure.at": "В точке", "measure.item": "Элемент {number}", + "measure.quick_copied": "Измерение скопировано", + "measure.quick_copy": "Щёлкните, чтобы скопировать измерение", + "measure.quick_edges": "Рёбер: {count}", + "measure.quick_faces": "Граней: {count}", "messages.ago_h": "{n} ч назад", "messages.ago_min": "{n} мин назад", "messages.ago_s": "{n} с назад", @@ -1738,6 +1994,11 @@ "not a move": "это не перемещение", "not a number": "не число", "open a sketch first": "сначала откройте эскиз", + "parameters.apply_live": "Применять каждое изменение при выходе из поля", + "parameters.apply_live_tip": "Выкл.: изменения ждут «Применить» или OK, после чего дизайн один раз пересобирается для всех сразу. «Отмена» или Esc отбрасывает их.", + "parameters.held": "Изменений в ожидании применения: {count}", + "parameters.held_hint": "Правьте выражения и единицы. Изменения ждут «Применить» или OK.", + "parameters.held_one": "1 изменение ждёт применения", "parts.automatic_name": "автоматически", "parts.dimension.across_flats": "Размер под ключ", "parts.dimension.bore": "Диаметр отверстия", @@ -1760,6 +2021,8 @@ "parts.family.set_screw": "Установочный винт с внутренним шестигранником и плоским концом", "parts.family.socket_head_cap_screw": "Винт с цилиндрической головкой и внутренним шестигранником", "parts.family.washer": "Плоская шайба", + "parts.holes": "Отверстия (необязательно)", + "parts.placement_hint": "выберите одно или несколько отверстий, чтобы посадить деталь в каждое, или OK поместит её в начало координат", "pattern.alignment_2": "Выравнивание 2", "pattern.alignment_hint": "Количество включает оригинал. Центрирование распределяет копии по обе стороны; при чётном количестве смена направления выбирает сторону с дополнительной копией.", "pattern.direction_1": "Направление 1", @@ -1921,6 +2184,8 @@ "prefs.origin_plane_names": "Имена плоскостей начала координат", "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.perspective_fov": "Угол обзора", "prefs.placement": "Размещение", "prefs.prefer_free_sketch_points": "Предпочитать подвижные точки эскиза, когда точки перекрываются", @@ -1948,6 +2213,8 @@ "prefs.quality_fastest": "быстрее всего", "prefs.quality_slowest": "медленнее всего", "prefs.quality_smoother": "глаже", + "prefs.quick_measure": "Быстрое измерение в углу вида", + "prefs.quick_measure_note": "Размер выделенного в правом нижнем углу вида: длина ребра, Ø и R для круглого, площадь грани, положение точки, расстояние и угол между двумя элементами, общее значение для нескольких рёбер или граней. Щелчок копирует его.", "prefs.render_previous_geometry_budget": "Использовать прежний бюджет геометрии предпросмотра рендера", "prefs.render_previous_geometry_budget_help": "Вернуть прежний фиксированный предел геометрии (512 МиБ на 64-разрядном настольном компьютере, 128 МиБ на остальных устройствах) вместо бюджета, зависящего от устройства.", "prefs.reports_saved": "Сохранено: {n}", @@ -1987,6 +2254,8 @@ "prefs.sketch_tab_always_note": "Вкладка «Эскиз» остаётся на ленте сразу после «Модель», пока не открыт ни один эскиз. Её инструменты рисования создают эскиз на выбранной плоскости или грани либо на плоскости, которую вы выберете следом, и сразу включают нужный инструмент.", "prefs.sketch_tracking_guides": "Направляющие выравнивания по центру и середине", "prefs.sketch_tracking_guides_note": "Наведите курсор на центр окружности или середину прямого отрезка, затем двигайтесь по горизонтали или вертикали, чтобы показать временную пунктирную направляющую.", + "prefs.sketch_tracking_points": "Направляющие выравнивания от концов и углов при рисовании", + "prefs.sketch_tracking_points_note": "Пока инструмент рисования ждёт точку, наведение на конец линии или дуги, угол прямоугольника или свободную точку показывает ту же временную пунктирную направляющую, что и для центра или середины. Выкл.: только центры и середины.", "prefs.sketching": "Эскизирование", "prefs.snap_drawing_note": "При рисовании или перетаскивании точки эскиза.", "prefs.source": "Источник", @@ -2009,6 +2278,16 @@ "prefs.theme_follow_off": "Выкл.", "prefs.theme_follow_stop": "Прекратить отслеживание", "prefs.theme_import_hint": "Щёлкните, чтобы выбрать файл темы, перетащите его сюда или нажмите Ctrl+V (Cmd+V на Mac) при скопированной теме", + "prefs.theme_option_opacity": "Непрозрачность", + "prefs.theme_option_opacity_help": "Настройте непрозрачность плавающих панелей. Нижний порог читаемости защищает текст поверх цветов видового экрана.", + "prefs.theme_option_tab_colours": "Цвета вкладок", + "prefs.theme_option_tab_colours_help": "Использовать цвета панелей и видового экрана этой темы для каждой вкладки ленты.", + "prefs.theme_option_tab_fade_ms": "Переход между вкладками", + "prefs.theme_option_tab_fade_ms_help": "Плавно менять цвета панелей и видового экрана при переключении вкладок. Ноль переключает мгновенно.", + "prefs.theme_options_badge": "Параметры", + "prefs.theme_options_more": "Ещё · {count}", + "prefs.theme_options_reset": "Сбросить", + "prefs.theme_options_unavailable": "Недоступно на этом устройстве", "prefs.theme_paste": "Вставить тему", "prefs.theme_paste_none": "В буфере обмена нет темы SolidCraft", "prefs.timeline_half_hour": "−30 мин", @@ -2029,6 +2308,8 @@ "prefs.under_cursor": "Под курсором", "prefs.units_precision": "Единицы и точность", "prefs.updates": "Обновления", + "prefs.value_boxes_in_view": "Показывать значения рядом с манипуляторами на виде", + "prefs.value_boxes_in_view_tip": "Значение команды (Расстояние, Радиус…) в рамке рядом с её манипулятором, поверх модели. Выкл.: вы вводите его в окне команды, которое получает клавиатуру после выбора, и ничто не закрывает модель.", "prefs.view": "Поведение вида", "prefs.view_confirm_corner": "OK и Отмена в углу вида", "prefs.view_confirm_corner_note": "Пока открыт диалог или эскиз, галочка и крестик в правом верхнем углу вида делают то же, что OK и Отмена в диалоге, либо завершают и отменяют эскиз. Навигатор видов сдвигается вниз под ними.", @@ -2038,6 +2319,12 @@ "prefs.viewport_background": "Фон", "prefs.viewport_background_colour": "Цвет", "prefs.viewport_background_theme": "Градиент темы", + "prefs.viewport_section_status": "Показывать состояние сечения на видовом экране", + "prefs.viewport_section_status_help": "Показывает настройку сечения на видовом экране. Esc и активная кнопка ленты отключают её. Выключите, чтобы сохранить прежние элементы управления сечением.", + "prefs.viewport_status_details": "Интерактивные сведения о видовом экране", + "prefs.viewport_status_details_help": "Редактируйте настройки активных анализов и сечения в стандартных карточках сведений. Выключите, чтобы вернуть клавиши анализа только для чтения и диалог «Сечение».", + "prefs.viewport_status_summary": "Сворачивать элементы состояния видового экрана", + "prefs.viewport_status_summary_help": "Сворачивать три и более элемента состояния видового экрана в один сводный значок. Выключите, чтобы показывать все элементы в полной строке.", "prefs.wheel": "Колесо", "prefs.wheel_trackpad": "Колесо и тачпад", "prefs.workspace": "Рабочее пространство", @@ -2889,6 +3176,8 @@ "render.output_ratio_refused": "Связанный размер превышает предел для этого метода. Прежний размер сохранён; разблокируйте соотношение или укажите меньшее значение.", "render.output_run_check": "Выполнить проверку", "render.output_size_limit": "{method}: до {dimension} px по стороне и {pixels} пикселей. Наибольший подходящий размер: {width} × {height}.", + "render.output_size_method_profile": "Чтобы отрендерить {width} × {height}, выберите {method} с профилем ресурсов CPU {profile} или меньший размер.", + "render.output_size_profile": "Чтобы отрендерить {width} × {height}, выберите профиль ресурсов CPU {profile} или меньший размер.", "render.output_swap_orientation": "Поменять местами ширину и высоту", "render.output_tools_inspector": "Измените размер и формат в инспекторе «Вывод».", "render.page_aging": "Старение и загрязнение", @@ -3089,6 +3378,7 @@ "render.size_4k_uhd": "4K UHD", "render.size_full_hd": "Full HD", "render.size_hd": "HD", + "render.size_needs_profile": "{size} · требуется {profile}", "render.size_presets": "Пресеты размера", "render.size_presets_pending": "Появится в редакторе «Вывод»", "render.sky_ground": "Земля · линейный RGB", @@ -3370,6 +3660,24 @@ "sheet.flange_type_contour": "Контурный", "sheet.flange_type_edge": "Краевой", "sheet.no_body": "нет тела листового металла: создайте его командой Фланец > Базовый или преобразуйте пластину в листовой металл командой «Преобразовать в листовой металл» (выберите её большую плоскую грань)", + "sheet.personal_rules.confirm_delete": "Подтвердить удаление", + "sheet.personal_rules.delete": "Удалить", + "sheet.personal_rules.delete_prompt": "Удалить это личное правило? Правила, уже применённые в дизайнах, не меняются.", + "sheet.personal_rules.full": "В личной библиотеке уже 64 правила.", + "sheet.personal_rules.invalid_name": "Введите короткое имя без переводов строк и управляющих символов.", + "sheet.personal_rules.invalid_values": "Заполните значения правила: каждое должно вычисляться в этом дизайне, толщина должна быть положительной, а коэффициент K — от 0 до 1.", + "sheet.personal_rules.missing": "Выбранное личное правило больше недоступно.", + "sheet.personal_rules.name": "Имя правила", + "sheet.personal_rules.name_exists": "Личное правило с таким именем уже существует. Используйте другое имя.", + "sheet.personal_rules.name_hint": "Уникальное имя", + "sheet.personal_rules.overwrite": "Сохранить изменения в выбранном личном правиле", + "sheet.personal_rules.rename": "Переименовать", + "sheet.personal_rules.rule": "Личное правило", + "sheet.personal_rules.save": "Сохранить как новое", + "sheet.personal_rules.scope": "Сохраняется в вашем профиле для всех дизайнов.", + "sheet.personal_rules.select": "Выберите правило", + "sheet.personal_rules.title": "Личные правила", + "sheet.personal_rules.unsupported": "В этом правиле есть значения, которые эта версия не может использовать.", "shortcut.json_error": "параметры должны быть в формате JSON: {error}", "shortcut.no_command": "команда `{id}` не найдена; текст перед JSON должен быть ID команды", "shortcut.pin": "Закрепить вверху поля S", @@ -3406,6 +3714,7 @@ "sketch_text.align_right": "По правому краю", "sketch_text.bold": "Полужирный", "sketch_text.builtin_font": "{family} (встроенный)", + "sketch_text.formula": "Начните с =, чтобы составить слова из параметров: ='W' + number(Width)", "sketch_text.italic": "Курсив", "sketch_text.loading_fonts": "Поиск установленных шрифтов…", "sketch_text.on_path": "Текст следует по траектории: кривая задаёт его положение", @@ -3544,6 +3853,8 @@ "ui.3d_mouse_section": "3D-мышь", "ui.3d_mouse_show_in_view": "Показывать 3D-мышь в 3D-виде", "ui.3d_mouse_show_in_view_tip": "Изображение вашей 3D-мыши в левом нижнем углу, которое двигается вместе с ней.", + "ui.3d_mouse_sketch_orbit": "Вращать эскизы 3D-мышью", + "ui.3d_mouse_sketch_orbit_tip": "3D-мышь вращает вид и при открытом эскизе, если смотреть на него прямо, как это умеет обычная мышь. Выкл.: она панорамирует и масштабирует эскиз, пока вы не наклоните вид.", "ui.3d_mouse_skip": "Моё устройство так не умеет", "ui.3d_mouse_skip_tip": "Пропускает это движение в обе стороны; калибровка его не учитывает.", "ui.3d_mouse_speed": "Скорость 3D-мыши", @@ -3612,6 +3923,7 @@ "ui.agent_chat_auth_terminal": "Выполните это в терминале, затем снова нажмите «Подключить»:", "ui.agent_chat_auth_title": "Вход в агент", "ui.agent_chat_cancel": "Отмена", + "ui.agent_chat_cannot_start": "Установлен, но не запускается: {error}", "ui.agent_chat_changes": "Изменений: {n}, один шаг отмены", "ui.agent_chat_changes_n": "Изменений: {n}", "ui.agent_chat_changes_of": "Изменений: {n}/{limit}", @@ -3704,9 +4016,12 @@ "ui.agent_chat_queued_stopped": "В очереди (остановлено, не отправлено)", "ui.agent_chat_refresh": "Обновить", "ui.agent_chat_refused": "Агенту не разрешено: {reason}", + "ui.agent_chat_reinstall": "{agent} установлен ({program}), но не запустился, когда SolidCraft его проверил: {error}\nВозможно, установка неполная или сделана для другого типа компьютера. Установите его заново командой `{install}`, убедитесь, что в терминале он выводит свою версию, затем нажмите «Обновить».", "ui.agent_chat_remove": "Убрать из очереди", "ui.agent_chat_remove_file": "Убрать из этого сообщения", "ui.agent_chat_remove_imported": "Убрать из этого сообщения; деталь останется в дизайне (Отмена удалит её)", + "ui.agent_chat_reply_cut_off": "Ответ достиг предела длины, не закончившись, поэтому может быть неполным. Попросите агента продолжить или дать более короткий ответ.", + "ui.agent_chat_reply_declined": "Модель отказалась отвечать на это сообщение.", "ui.agent_chat_retry": "Повторить", "ui.agent_chat_right": "Справа", "ui.agent_chat_save": "Сохранить", @@ -3765,12 +4080,17 @@ "ui.ai_copy_suffix": "копия", "ui.ai_custom_url": "Свой URL", "ui.ai_default": "По умолчанию", + "ui.ai_edit": "Изменить", + "ui.ai_edit_agent_title": "Изменить агент", + "ui.ai_edit_gone": "Этот агент удалён, сохранять нечего.", + "ui.ai_edit_tip": "Измените имя, команду или конечную точку этого агента. Сохранённый ключ остаётся скрытым.", "ui.ai_http_busy": "конечная точка занята или превысила лимит; повторите позже", "ui.ai_http_key": "конечная точка отклонила ключ: проверьте его в Настройки > ИИ", "ui.ai_http_not_found": "нет такой конечной точки или модели: проверьте базовый URL и имя модели", "ui.ai_http_too_large": "разговор слишком велик для этой конечной точки; начните новый чат", "ui.ai_key": "Ключ API", "ui.ai_key_hint": "необязательно; хранится в системной связке ключей", + "ui.ai_key_keep_hint": "оставьте пустым, чтобы сохранить имеющийся ключ", "ui.ai_key_needs_https": "Ключ отправляется только по HTTPS или на этот компьютер.", "ui.ai_key_note": "Хранится в системной связке ключей, а не в настройках SolidCraft. Для локального сервера оставьте пустым.", "ui.ai_keychain_error": "Системная связка ключей отказала: {error}", @@ -3802,6 +4122,7 @@ "ui.ai_request_timeout": "Тайм-аут запроса", "ui.ai_request_timeout_hint": "Сколько может длиться один запрос к модели на этой конечной точке: от 30 с до часа. Увеличьте для медленной локальной модели.", "ui.ai_reset": "Сбросить", + "ui.ai_save": "Сохранить", "ui.ai_settings": "Настройки ИИ…", "ui.ai_show_features": "Показывать функции ИИ", "ui.ai_show_features_note": "Чат с агентом: панель, в которой уже используемый вами кодирующий агент или выбранная конечная точка модели работает с открытым дизайном. Выключено: нет кнопки ИИ, сочетания клавиш, пункта меню и панели, ничего не ищется и не запускается.", @@ -3973,6 +4294,40 @@ "ui.design_properties.project": "Проект", "ui.design_properties.revision": "Редакция", "ui.design_saved": "Сохранено: {path}", + "ui.details_access_caption": "Грани показывают, может ли инструмент достать до них с выбранного направления.", + "ui.details_angle": "Угол", + "ui.details_continuity_caption": "Правка выбранного шва. Остальные швы сохраняют свои настройки.", + "ui.details_continuity_stale": "Прежняя геометрия: повторите анализ, чтобы править или кадрировать этот шов.", + "ui.details_copy_list": "Копировать список", + "ui.details_curvature": "Допуск кривизны", + "ui.details_curvature_caption": "Грани окрашены по локальному радиусу кривизны.", + "ui.details_direction": "Направление инструмента", + "ui.details_draft_caption": "Грани окрашены по углу к направлению вытягивания.", + "ui.details_edit_seam": "Править шов", + "ui.details_fixture": "Образец компонента — только пример данных", + "ui.details_fixture_caption": "Образец компонента с иллюстративными значениями; это не результат по модели.", + "ui.details_fixture_problem": "Пример находки с проблемой", + "ui.details_fixture_readout": "Пример измеренного значения", + "ui.details_flip": "Перевернуть", + "ui.details_gap": "Зазор", + "ui.details_hatch": "Штриховать срезанные грани", + "ui.details_jump": "Скачок кривизны", + "ui.details_key": "Легенда", + "ui.details_offset": "Смещение", + "ui.details_plane": "Плоскость", + "ui.details_pull": "Направление вытягивания", + "ui.details_radius": "Радиус", + "ui.details_readout": "Показание", + "ui.details_samples": "Точки выборки", + "ui.details_seam": "{body} · Ребро {edge}", + "ui.details_settings": "Настройки", + "ui.details_sharp_gap": "Излом или зазор", + "ui.details_show_all": "Показать все {count}", + "ui.details_smooth": "Гладко", + "ui.details_stripes": "Полосы", + "ui.details_target": "Кадрировать геометрию", + "ui.details_worst_seams": "Худшие швы", + "ui.details_zebra_caption": "Отражённые полосы показывают, насколько плавно стыкуются грани.", "ui.diameter": "Диаметр", "ui.dimension": "Размер", "ui.direct_interaction": "Прямое взаимодействие", @@ -4033,6 +4388,7 @@ "ui.equation_y_of_x": "y(x)", "ui.equivalent_radius": "Эквивалентный радиус", "ui.expand": "Развернуть", + "ui.explode_needs_two_components": "«Разобранный вид» раздвигает компоненты, поэтому в дизайне их должно быть два или больше («Новый компонент», «Вставить дизайн» или «Создать компоненты из тел»)", "ui.exportDxf": "Экспорт DXF…", "ui.export_ellipsis_keymap": "Экспорт…", "ui.export_keymap_tip": "Сочетания клавиш, избранное панели S и панель инструментов — в файл", @@ -4321,6 +4677,11 @@ "ui.plugins_skipped": "Не удалось прочитать папок: {count}.", "ui.plugins_version": "{version}", "ui.point": "Точка", + "ui.point_cloud_add_filter": "Добавить фильтр…", + "ui.point_cloud_filter_by": "Фильтровать по", + "ui.point_cloud_legend_filter": "Показано и скрыто в легенде выше", + "ui.point_cloud_pause": "Пауза", + "ui.point_cloud_play": "Воспроизвести", "ui.point_under_the_cursor": "Точка под курсором", "ui.points": "Точки", "ui.polygon": "Многоугольник", @@ -4346,6 +4707,7 @@ "ui.properties.inertia_y": "Iyx, Iyy, Iyz", "ui.properties.inertia_z": "Izx, Izy, Izz", "ui.properties.measuring": "Измерение…", + "ui.properties.measuring_bodies": "Измерение… {done} из {total} тел", "ui.properties.measuring_failed": "Измерение остановлено, не закончив работу.", "ui.properties.principal_axis": "Главная ось {number}", "ui.properties.principal_moments": "Главные моменты", @@ -4411,6 +4773,7 @@ "ui.selectOther": "Выбрать другое…", "ui.selectTangentChain": "Выбрать касательную цепочку", "ui.select_all": "Выделить всё", + "ui.select_components_by_face": "Выбор компонентов (грань выбирает свой компонент)", "ui.selection": "Выделение", "ui.selection_modifiers": "Модификаторы выбора", "ui.selection_shift_add_only": "Shift+щелчок добавляет без удаления", @@ -4452,6 +4815,11 @@ "ui.sketch_intersect_selection_filter": "Фильтр выделения", "ui.sketch_modifiers": "Модификаторы эскиза", "ui.sketch_project_geometry": "Геометрия", + "ui.sketch_report.all_constrained": "Всё полностью определено.", + "ui.sketch_report.dof": "Степени свободы", + "ui.sketch_report.loose": "Определён не полностью", + "ui.sketch_report.status": "Состояние", + "ui.sketch_report.title": "Отчёт об эскизе", "ui.sketch_scale": "Масштаб эскиза", "ui.sketches": "Эскизы", "ui.slice": "Срез", @@ -4561,7 +4929,14 @@ "ui.viewport_status_key": "Показать легенду для {name}", "ui.viewport_status_minimum_radius": "Минимальный радиус", "ui.viewport_status_pairs": "Пар: {count}", + "ui.viewport_status_section_hatch": "{setting} · штриховка", + "ui.viewport_status_section_plane": "Текущая плоскость", + "ui.viewport_status_section_tilted": "наклонено · {offset}", "ui.viewport_status_stripes": "Полос: {count}", + "ui.viewport_status_summary_clear": "Выключить всё", + "ui.viewport_status_summary_heading": "На этом виде", + "ui.viewport_status_summary_label": "{name} и ещё элементов: {count}", + "ui.viewport_status_summary_more": "+{count}", "ui.viewport_theme_follow": "Следовать теме", "ui.visual_shaded_hidden_edges": "Заполненный с невидимыми рёбрами", "ui.visual_shaded_with_edges": "Заполненный с рёбрами", diff --git a/assets/locales/sr.json b/assets/locales/sr.json index 18c486b8bd..e7407259e4 100644 --- a/assets/locales/sr.json +++ b/assets/locales/sr.json @@ -60,6 +60,7 @@ "BLEND SURFACE": "ПРЕЛАЗНА ПОВРШ", "BOUNDARY SURFACE": "ГРАНИЧНА ПОВРШ", "BROWSER": "СТАБЛО МОДЕЛА", + "Back to the original order": "Врати на првобитни редослед", "Ball": "Кугласто", "Bar": "Шипка", "Base Center": "Центар основе", @@ -595,6 +596,8 @@ "Snap to face and edge centres": "Привлачи на центре лица и ивица", "SolidCraft cannot submit reports directly. Submit on GitHub; no token is needed in the app.": "SolidCraft не може директно да шаље извештаје. Пошаљите на GitHub; у апликацији није потребан токен.", "SolidCraft closed before these designs were saved. Their last autosave can be opened.": "SolidCraft се затворио пре него што су ови дизајни сачувани. Може се отворити њихово последње аутоматско чување.", + "Sort A to Z": "Поређај од А до Ш", + "Sort Z to A": "Поређај од Ш до А", "Spacing 2 must be a non-zero number": "Размак 2 мора бити број различит од нуле", "Spacing must be a non-zero number": "Размак мора бити број различит од нуле", "Specialise": "Специјализуј", @@ -819,6 +822,7 @@ "cmd.appearance_pick_up": "Преузми изглед", "cmd.appearance_replace": "Замени изглед", "cmd.assembly_bom": "Саставница", + "cmd.assembly_mass_properties": "Својства масе", "cmd.body.print_face": "Површина за штампу", "cmd.body_rename": "Преименуј тело", "cmd.browser_collapse_all": "Скупи све", @@ -1244,6 +1248,7 @@ "cmd.view.render_browser": "Стабло рендеровања", "cmd.view.render_dock": "Премести панел рендеровања", "cmd.view.render_focus": "Фокусни приказ рендеровања", + "cmd.view.status_details": "Детаљи статуса у прозору приказа", "cmd.view.status_items": "Ставке статуса у прозору приказа", "cmd.view_ambient_occlusion": "Амбијентална оклузија", "cmd.view_angle_readout": "Углови приказа", @@ -1387,21 +1392,168 @@ "from the rule": "из правила", "help.apache": "Apache лиценца 2.0", "help.appearance.assign": "Даје изабраним телима, лицима или компонентама боју или изглед материјала, без промене њихове тежине. Користите га да разликујете делове или прегледате завршну обраду.", + "help.assembly.bom": "Наводи делове дизајна или компоненте са њиховим бројем комада, по броју дела, и може да сачува списак као CSV или JSON. Користите је као списак делова за наручивање и монтажу.", "help.attributions": "Атрибуције (ATTRIBUTION.md)", "help.build": "Верзија {version} · commit {commit} · изграђено {date}", + "help.canvas.decal": "Поставља слику на раван лице модела, као налепницу. Користите је да на делу прикажете ознаку, логотип или одштампан узорак.", + "help.canvas.edit": "Мења платно: његов положај, величину, угао, непрозирност, пресликавање, видљивост или назив. Користите га да референтну слику поравнате са скицом коју цртате преко ње.", + "help.canvas.insert": "Поставља PNG или JPEG слику на раван или лице као провидну референцу. Користите га да у скици прецртате фотографију или цртеж, са сликом скалираном на познату величину.", + "help.component.break_link": "Одваја повезану компоненту од њене датотеке дизајна, а њена тела остају каква јесу. Користите је када део више не треба да прати измене изворне датотеке.", + "help.component.capture_position": "Задржава положаје делова које сте ручно померили, па они постају нови положаји тих делова. Користите га након превлачења делова на место које ниједан спој не држи.", + "help.component.create": "Додаје нову, празну компоненту дизајну, унутар активне компоненте осим ако изаберете другу надређену компоненту. Користите је да нови део склопа добије своје место за тела и скице.", "help.component.finish_edit": "Чува део који мењате на месту, враћа се у његов склоп и ажурира склоп према њему. Користите када завршите измене повезаног дела.", + "help.component.from_bodies": "Претвара свако изабрано тело у засебну компоненту, названу по телу. Користите га када дизајн направљен од појединачних тела треба да постане склоп чији се делови могу спајати и померати.", + "help.component.insert_design": "Умеће други сачувани дизајн као компоненту повезану са његовом датотеком, а његова пуна тела се постављају онако како су у њему. Користите га да поново употребите део у склопу и да касније измене преузмете командом Ажурирај везе.", + "help.component.properties": "Приказује запремину, масу, центар масе и моменте инерције компоненте, према материјалима њених тела. Користите га да проверите тежину и равнотежу склопа.", + "help.component.revert_position": "Враћа делове које сте ручно померили на њихова претходна места и одбацује померања која нису забележена. Користите га да поништите пробна померања у склопу.", + "help.component.update_links": "Поново чита повезане датотеке дизајна и замењује тела која су се променила. Користите га након измене дела који овај склоп користи.", + "help.config.configure": "Додаје параметре, операције, материјале или изгледе као колоне табеле конфигурација. Користите га да направите варијанте једног дизајна, на пример величине једног дела.", + "help.config.preview": "Проверава да ли се ред табеле конфигурација успешно поново гради, без промене дизајна или његове историје. Користите га пре преласка на варијанту у коју нисте сигурни.", + "help.config.rules": "Приказује правила табеле конфигурација, њене редове и који ред је активан. Користите га да видите како су подешене варијанте дизајна.", + "help.config.table": "Приказује табелу конфигурација: један ред по варијанти, једну колону по параметру или операцији и да ли дизајн још одговара активном реду. Користите је да прегледате варијанте дизајна.", "help.connect_note": "Поново покрените сваки асистент да учита SolidCraft сервер. Свака датотека је безбедносно копирана као .bak пре промене.", "help.connect_title": "Повежи AI асистенте", + "help.construct.axis.edge": "Прави помоћну осу дуж праве ивице или линије скице. Користите је као осу за ротацију, кружни низ или спој.", + "help.construct.axis.normal_to_face": "Прави помоћну осу кроз тачку, окомиту на лице. Користите је као осу која излази право из површине.", + "help.construct.axis.through_cylinder": "Прави помоћну осу кроз центар цилиндричног, конусног или торусног лица, или кружне ивице. Користите је да операције окрећете или распоређујете у низ око округлог дела.", + "help.construct.axis.two_planes": "Прави помоћну осу тамо где се састају две равни или равна лица. Користите је када је оса која вам треба линија између два лица.", + "help.construct.axis.two_points": "Прави помоћну осу кроз две тачке, врхове или тачке скице. Користите је за осу која пролази кроз два позната положаја.", + "help.construct.plane.along_path": "Прави помоћну раван окомиту на ивицу или криву, на положају дуж ње. Користите је да скицирате профил за повлачење или попречни пресек путање.", + "help.construct.plane.at_angle": "Прави помоћну раван заокренуту под углом у односу на раван или лице, око осе или ивице. Користите је за нагнуте скице, на пример за угаони носач.", + "help.construct.plane.midplane": "Прави помоћну раван на половини између две равни или равна лица, или раван која полови угао под којим се састају. Користите је као раван симетрије за пресликавање.", + "help.construct.plane.offset": "Прави помоћну раван паралелну равни или равном лицу, на задатом растојању или кроз тачку. Користите је да скицирате на одређеној висини изнад лица.", + "help.construct.plane.perpendicular": "Прави помоћну раван која садржи линију или ивицу и стоји окомито на раван или лице. Користите је да скицирате усправно на моделу дуж ивице.", + "help.construct.plane.tangent": "Прави помоћну раван која у једној тачки додирује цилиндар, конус, сферу или торус. Користите је да скицирате на боку округлог дела.", + "help.construct.plane.three_points": "Прави помоћну раван кроз три тачке. Користите је за раван одређену положајима на моделу, на пример за три угла.", + "help.construct.plane.two_edges": "Прави помоћну раван кроз две праве ивице или линије које леже у једној равни. Користите је за раван кроз део, на пример кроз две наспрамне ивице.", + "help.construct.point.along_path": "Прави помоћну тачку на положају дуж ивице или криве. Користите је као референцу негде на путањи.", + "help.construct.point.center": "Прави помоћну тачку у центру кружне ивице, круга или лука скице, сфере или торуса. Користите је као референцу за рупе, осе и спојеве.", + "help.construct.point.edge_plane": "Прави помоћну тачку где ивица или линија пролази кроз раван или равно лице. Користите је да означите где путања сече раван.", + "help.construct.point.three_planes": "Прави помоћну тачку где се састају три равни или равна лица. Користите је као референцу за угао, чак и тамо где модел нема врх.", + "help.construct.point.two_edges": "Прави помоћну тачку где се две ивице или линије састају или су најближе једна другој. Користите је као референцу у виртуелном углу.", + "help.construct.point.vertex": "Прави помоћну тачку на врху или тачки скице. Користите је као референцу која остаје и када су скица или тело око ње сакривени.", + "help.contact.check": "Наводи парове делова у скуповима контаката који у свом тренутном положају пролазе један кроз други. Користите га да пронађете сударе пре него што покренете спојеве или студију кретања.", + "help.contact.create": "Групише два или више делова у скуп контаката чија тела не могу да прођу један кроз други док се спојеви крећу. Користите га за делове који треба да стану када се додирну, као што је полуга уз свој граничник.", + "help.contact.disable_all": "Искључује контакт, па покренути спојеви и студије кретања више не стају тамо где се делови додирују. Користите га да поново слободно померате делове или да велики склоп остане брз.", + "help.contact.edit": "Мења скуп контаката: делове које садржи, његов назив или да ли је потиснут; може и да избрише скуп. Користите га да подесите који делови не могу да прођу један кроз други.", + "help.contact.enable_all": "Ставља сваки део склопа у један скуп контаката, па никоја два дела не могу да прођу један кроз други док се спојеви крећу. Користите га за брзу проверу целог механизма.", + "help.contact.enable_sets": "Укључује контакт за скупове контаката које сте дефинисали, па покренути спојеви и студије кретања стају тамо где би њихови делови прошли један кроз други. Користите га да вратите своје скупове након искључивања контакта.", + "help.contact.list": "Наводи скупове контаката дизајна, делове у сваком од њих и да ли је контакт укључен. Користите га да проверите како је контакт подешен пре него што покренете склоп.", + "help.document.inspect": "Приказује преглед дизајна: његове компоненте, тела, скице, операције и параметре. Користите га да се снађете у дизајну који нисте сами направили.", + "help.document.units": "Мења јединице у којима дизајн приказује дужине, на пример mm или инчи. Геометрија задржава своју величину; мењају се само приказ и обични бројеви.", + "help.edit.checkpoint": "Памти дизајн, активну скицу и историју поништавања онакве какви су сада. Користите је пре него што испробате нешто ризично од чега ћете се можда хтети вратити.", + "help.edit.restore_checkpoint": "Враћа се на контролну тачку, укључујући историју поништавања. Користите га да једним кораком одбаците све што је урађено од контролне тачке.", + "help.explode.create": "Прави растављени приказ који делове одгура од центра склопа, или за померања која зададете. Користите га да прикажете како се склоп саставља, за упутства или презентације.", "help.face.delete": "Уклања изабрана лица и пушта да суседна лица затворе отвор, или оставља отвор отвореним као површ. Користите га да уклоните заобљену ивицу, рупу или испупчење које смета.", + "help.file.insert_mesh": "Додаје 3MF, STL, OBJ, PLY или glTF датотеку дизајну као мрежна тела у новој компоненти. Користите га да преузети или скенирани модел поставите поред свог дизајна као референцу.", + "help.file.insert_step": "Додаје STEP, IGES, FreeCAD или Rhino датотеку дизајну као пуна тела у новој компоненти; делови склопа постају угнежђене компоненте. Користите га да унесете моделе од добављача или из других CAD програма.", + "help.file.save_mesh": "Чува тела као STL, 3MF или OBJ мрежу, са глаткоћом закривљених лица коју изаберете. Користите је да припремите делове за 3D штампу или за програме који читају мреже.", + "help.file.send_to_slicer": "Чува изабрана тела као 3MF датотеку и отвара је у слајсеру који изаберете. Користите га да једним кораком пређете од модела до штампе.", "help.font_details": "Интерфејс користи уграђене фонтове библиотеке egui (Ubuntu Light и Hack, под лиценцама Ubuntu Font Licence и MIT/Bitstream Vera; Noto Emoji и фонт емоџи икона, под лиценцама OFL и MIT). Текст скице користи Liberation Sans (SIL OFL 1.1). Необавезни пакет фонтова Crafting Apps, craft-fonts (BIZ UDPGothic, Shippori Mincho, BIZ UDMincho, Noto Sans CJK SC, Noto Sans Arabic; SIL OFL 1.1), није укључен у издања SolidCraft-а.", "help.fonts": "Фонтови", + "help.frame.member": "Поставља профил, на пример цев или алуминијумски екструдирани профил, дуж линија скице оквира, по један елемент по линији. Користите га да направите оквире, регале и заварене конструкције.", + "help.inspect.accessibility": "Боји лица до којих алат може да допре из једног смера и она до којих не може. Користите је да проверите део за обраду или израду у калупу са те стране.", + "help.inspect.center_of_mass": "Приказује центар масе тела, сваког посебно и свих заједно, при уједначеној густини. Користите га да проверите равнотежу и где ће се део преврнути.", + "help.inspect.colored_components": "Даје свакој компоненти сопствену привремену боју, без промене изгледа. Користите га да на први поглед разликујете делове склопа.", + "help.inspect.compare": "Упоређује два тела и показује где једно има материјал којег друго нема, са запремином и одступањем. Користите га да проверите измењен или увезен део у односу на оригинал.", + "help.inspect.continuity": "Проверава колико глатко се лица спајају дуж својих ивица: размаке, прегибе или скокове закривљености, и осенчава шавове. Користите је да пронађете где се површи не спајају уредно.", + "help.inspect.curvature_comb": "Црта чешљеве дуж кривих, ивица или лица чији зуби показују колико су јако закривљени. Користите је да уочите равна места, прегибе и неуједначену закривљеност.", + "help.inspect.curvature_map": "Боји лица према јачини закривљености, црвено где је полупречник мали. Користите је да пронађете оштре кривине и да процените колико је површ глатка.", + "help.inspect.draft": "Боји лица према углу нагиба у односу на смер вађења из калупа и означава она са премало нагиба. Користите је да проверите део пре израде у калупу или ливења.", + "help.inspect.environment_map": "Приказује модел са рефлектујућом завршном обрадом налик огледалу. Користите је да процените ток површи по начину на који се одблесци простиру преко њих.", + "help.inspect.interference": "Проналази тела која се преклапају и приказује свако преклапање са његовом запремином. Користите га да проверите да ли у склопу има делова који се сударају.", + "help.inspect.isocurve": "Црта изопараметарске криве лица, мрежу на којој је површ изграђена. Користите је да видите како површ тече и где се згушњава.", + "help.inspect.measure": "Мери дужине, растојања, углове, површине и полупречнике између онога што изаберете, или запремину и масу тела. Користите га да проверите мере и зазоре.", + "help.inspect.minimum_radius": "Проналази најмањи полупречник закривљености кривих, ивица или лица и где се налази. Користите је да проверите да ли алат или млазница може да прати облик.", + "help.inspect.section": "Расеца приказ дуж равни и шрафира пресечена лица, без промене модела. Користите га да погледате унутрашњост делова и склопова.", + "help.inspect.zebra": "Приказује црне и беле пруге одбијене на лицима модела. Користите је да процените глаткоћу: пруге које се прекидају или ломе на ивици показују лош прелаз.", + "help.joint.as_built": "Спаја два дела тамо где већ јесу, не померајући ниједан, и одређује како се још могу кретати. Користите га за делове које сте већ поставили у њихов коначни положај.", + "help.joint.cam": "Повезује брег који се окреће са пратиоцем, па пратилац клизи или се њише док обрис брега пролази испод њега. Користите га за брегове, ексцентре и погоне вентила.", + "help.joint.create": "Спаја два дела у исходиштима споја која изаберете и помера други део на место; врста споја одређује како се још могу кретати. Користите га да направите склоп чији се делови крећу као прави механизам.", + "help.joint.drive": "Задаје угао или растојање споја и са њим помера делове, у оквиру његових граница; повезани спојеви и затворене петље га прате. Користите га да проверите да ли се механизам креће како је замишљено.", + "help.joint.gear": "Повезује два окретна споја преносним односом, па покретање једног окреће други. Користите га за чеоне и унутрашње зупчанике, са односом из полупречника подеоних кругова или броја зуба.", + "help.joint.limits": "Одређује докле спој сме да се окреће или клизи, као минимум и максимум, или брише те границе. Користите га да шарку, клизач или полугу задржите у њиховом стварном опсегу кретања.", + "help.joint.motion_link": "Спреже два споја односом и помаком, па покретање једног помера други. Користите је за сталну везу између спојева коју Зупчаник, Зупчаста летва и зупчаник или Завртањ не покривају.", + "help.joint.origin": "Поставља именовано исходиште споја на део: у центар лица или округлог краја, средину ивице, угао или тачку. Користите га да припремите места на која се спојеви причвршћују; исходиште изабрано на лицу прати лице када део промени величину.", + "help.joint.rack_pinion": "Повезује мали зупчаник који се окреће са зупчастом летвом која клизи, па се летва помера за дужину подеоног круга коју зупчаник пређе котрљањем. Користите га за погоне са зупчастом летвом, управљање и линеарне столове.", + "help.joint.reach": "Помера тачку на делу ка циљу променом спојева на њеном ланцу, у оквиру њихових граница. Користите га да механизам поставите према томе где би његов крај требало да буде, на пример врх руке робота.", + "help.joint.rigid_group": "Закључава изабране делове један за други онакве какви јесу, па се крећу као једно. Користите је за делове који припадају заједно и међу којима нема кретања.", + "help.joint.screw": "Повезује окретање и клизање кораком, па сваки окретај помера за то растојање. Користите га за навојна вретена, завртње и навојне осовине.", + "help.library.apply": "Примењује материјал или изглед из датотеке локалне библиотеке на тела, лица или компоненте. Користите га да поново употребите материјале које сте сачували из другог дизајна.", + "help.library.export": "Чува материјале и изгледе дизајна у датотеку библиотеке. Користите га да их делите или поново користите у другим дизајнима.", + "help.library.inspect": "Проверава датотеку библиотеке и наводи њене материјале и изгледе, без промене дизајна. Користите га да видите шта библиотека садржи пре него што је примените.", "help.licences": "Лиценце", "help.material.assign": "Одређује од чега су изабрана тела направљена, нпр. челик, алуминијум или ABS. Користите га да маса и тежина одговарају стварном делу.", + "help.material.properties.validate": "Проверава да ли су бројеви на картици података о материјалу међусобно усклађени и да ли недостају вредности. Користите га пре него што се ослоните на податке за масу или крутост.", + "help.mesh.convert": "Поново гради мрежно тело, на пример STL, као тело које се може мењати, са равним и цилиндричним лицима. Користите га да мењате увезене механичке делове као моделована тела.", "help.mit": "MIT лиценца", + "help.motion.study": "Задаје вредности спојева у корацима дуж временске осе и репродукује их као анимацију. Користите је да гледате како се механизам креће кроз низ положаја и успут проверите зазоре.", + "help.motion.trace": "Прати путању којом иде тачка на делу док се студија кретања репродукује или спој покреће и, када потврдите, чува је као 3D криву скице. Користите га да видите докле механизам досеже или да на његовој путањи градите даље.", "help.notice": "NOTICE", + "help.occurrence.ground": "Учвршћује део на месту или га поново ослобађа, тако да спојеви померају остале делове око њега. Користите га за оквир или основу на којој је изграђен остатак склопа.", "help.occurrence.move": "Помера део склопа само дуж слобода које дозвољавају његови спојеви. Изаберите лице дела, а затим превуците доступну стрелицу или лук.", "help.parameters.change": "Отвара табелу именованих вредности и израза који управљају котама дизајна. Користите је да промените меру на једном месту, а све операције које је користе је прате.", + "help.parameters.export": "Чува параметре дизајна као CSV или JSON. Користите га да их уређујете у табели или пренесете у други дизајн.", + "help.parameters.graph": "Показује који параметри и операције користе које друге, као и евентуалне петље или грешке међу њима. Користите га да видите на шта ће утицати промена параметра.", + "help.parameters.import": "Учитава параметре из CSV или JSON у дизајн, и то све или ниједан. Користите га да унесете вредности из табеле или другог дизајна.", + "help.parameters.move": "Помера кориснички параметар испред или иза другог на списку. Користите га да сродни параметри остану заједно.", + "help.parts.fastener": "Додаје стандардни завртањ, вијак, навртку, подлошку, клин или лежај у величини коју изаберете. Постављен на рупу, належе у рупу помоћу споја, па не морате сами да моделујете спојне елементе.", + "help.parts.insert": "Умеће стандардни део у породици, величини и дужини које изаберете. Постављен на обод или зид рупе, належе на рупу крутим спојем.", + "help.parts.library": "Отвара стандардне делове: завртње, вијке, навртке, подлошке, завртње без главе, паралелне клинове (колчиће) и лежајеве у стандардним величинама. Користите га да додате стандардни део уместо да га сами моделујете.", + "help.parts.resize": "Мења величину или дужину уметнутог стандардног дела и поново га гради; његови спојеви га држе на месту. Користите га када се покаже да је завртањ предугачак или када рупа захтева другу величину.", + "help.plastic.assign_rule": "Додељује правило за пластику телима, па њихове пластичне операције преузимају његов нагиб и зазор као подразумеване вредности. Користите га да једном подесите део израђен у калупу за све његове операције.", + "help.plastic.boss": "Додаје испуст за завртањ на лицу: стуб са необавезном рупом, нагибом, заобљењем у корену и потпорним ребрима. Користите га за места за завртње у кућиштима израђеним у калупу или штампаним.", + "help.plastic.lip": "Додаје усну или одговарајући жлеб дуж обода издубљеног тела. Користите је да се две половине кућишта позиционирају једна на другу и чврсто затворе.", + "help.plastic.manage_rules": "Прави и мења правила за пластику: материјал, дебљину зида, полупречнике, зазор и нагиб. Користите их да операције дела израђеног у калупу буду усклађене.", + "help.plastic.rest": "Додаје ослонац на лицу: округло или правоугаоно постоље, пуно или шупље, са нагибом. Користите га као подлогу или дистанцер у кућишту израђеном у калупу.", + "help.plastic.snap_fit": "Додаје куку шкљоцајућег споја на лицу, са њеном дужином, дебљином и зупцем. Користите га да делове спојите шкљоцањем, без завртања.", "help.pointcloud.insert": "Уметните PLY, XYZ или PTS скенирање као облак тачака поред модела. Користите га као референцу при изради модела на основу измерених података.", + "help.sheet.convert": "Претвара плочу или издубљено тело једнаке дебљине у тело од лима, са савијањима на ивицама које изаберете. Користите га да постојећи део буде могуће развити.", + "help.sheet.export_bend_table": "Чува табелу савијања тела од лима као CSV или JSON: углове, полупречнике и положаје савијања у развијеном облику. Користите је за пресу за савијање или радионицу.", + "help.sheet.export_dxf": "Чува развијени облик као DXF, са обрисима, линијама савијања и необавезним белешкама о савијању. Користите га за ласерско, плазма или водено сечење.", + "help.sheet.flange": "Додаје прирубницу: основну плочу из скице, зид дуж отворене контуре скице или савијени зид на ивицама лима. Користите је да изградите делове од лима.", + "help.sheet.flat_pattern": "Прави развијени облик тела од лима, са његовом величином, изрезима и линијама савијања. Користите га да проверите заготовак пре сечења.", + "help.sheet.fold": "Савија лим дуж линије скице за задати угао и полупречник. Користите га да пресавијете раван лим након што сте у њега исекли изрезе.", + "help.sheet.hem": "Пресавија ивицу лима натраг на саму себе. Користите га за безбедне, круте ивице без оштрог грата.", + "help.sheet.manage_rules": "Прави и мења правила за лим: дебљину, полупречник савијања, K-фактор, растерећења и размаке. Користите их да ускладите материјал и алате своје радионице.", + "help.sheet.path_flange": "Додаје низ прирубница дуж ивице лима, пратећи отворени ланац линија скице. Користите је за профиле као што су U или Z облик у једном кораку.", + "help.sheet.perforate": "Додаје поље рупа преко целог равног лима, у помереном или квадратном распореду. Користите га за отворе за проветравање, решетке и лакше панеле.", + "help.sheet.refold": "Поново савија развијено тело од лима. Користите га након додавања операција на раван лим.", + "help.sheet.unfold": "Развија сва савијања тела од лима. Користите га да додате рупе или изрезе који морају да пређу преко савијања, а затим поново пресавијте.", + "help.sketch.arc.center_point": "Црта кружни лук око центра, од почетне до крајње тачке. Користите га када је центар лука познат, на пример за полупречник око рупе или осовине.", + "help.sketch.arc.tangent": "Црта лук који почиње на крају линије или лука и наставља тангентно на њега. Користите га да ланац наставите глатком кривином уместо углом.", + "help.sketch.arc.three_point": "Црта лук од почетне до крајње тачке кроз трећу тачку. Користите га када лук мора да прође кроз задате тачке, а не око познатог центра.", + "help.sketch.arc3d": "Црта тачан кружни лук у простору кроз три тачке, са укљученом 3D скицом. Користите га за кривине путања и водилица које напуштају раван скице.", + "help.sketch.auto_constrain": "Додаје ограничења која скица скоро већ има, као што су хоризонталност, паралелност или подударност, а затим је котира док није потпуно дефинисана. Користите ову команду да једним кораком учврстите грубо нацртану скицу.", + "help.sketch.auto_constrain_from_datum": "Ради као команда Аутоматска ограничења, али положаје мери од референтне тачке коју изаберете уместо од исходишта. Користите је када је део распоређен од једне референтне тачке, на пример угла или рупе.", + "help.sketch.blend_curve": "Спаја крајеве две криве глатким сплајном који је тангентан на обе, или и непрекидан по закривљености ако тако изаберете. Користите је да премостите размак између кривих без прегиба.", + "help.sketch.break": "Дели криву на делове тамо где је секу друге криве или у тачкама које поставите на њу. Користите га када део криве треба да има своје ограничење или да се сам избрише.", + "help.sketch.cancel": "Напушта скицу и одбацује све што је измењено откако је направљена или отворена за измену. Поништи враћа измене.", + "help.sketch.centerline": "Пребацује линије између обичних линија и средишњих линија, оса које профили изостављају. Користите средишњу линију као осу за ротацију, пресликавање или ограничење симетрије.", + "help.sketch.chamfer.distance_angle": "Скошава угао између две линије; задаје се растојањем дуж прве линије и углом. Користите га када цртеж даје скошење као дужину и угао, на пример 2 mm × 30°.", + "help.sketch.chamfer.equal_distance": "Скошава угао између две линије за исто растојање уназад дуж обе. Користите га за уобичајено скошење од 45° на обрису.", + "help.sketch.chamfer.two_distance": "Скошава угао између две линије; задаје се засебним растојањем дуж сваке линије. Користите га за скошења која нису од 45°, задата као две дужине.", + "help.sketch.circle.center": "Црта круг из његовог центра и пречника. Користите га за рупе, испусте и друге округле облике чији је центар познат.", + "help.sketch.circle.three_point": "Црта круг кроз три тачке. Користите га када круг мора да прође кроз задате тачке, на пример кроз тачке постојеће геометрије.", + "help.sketch.circle.three_tangent": "Црта круг који додирује три елемента: линије, кругове или лукове. Користите га да уклопите круг у простор између три криве, на пример као уписани круг троугла.", + "help.sketch.circle.two_point": "Црта круг из две крајње тачке његовог пречника. Користите га када су познате тачке на ивици круга, а не његов центар.", + "help.sketch.circle.two_tangent": "Црта круг задате величине који додирује два елемента: линије, кругове или лукове. Користите га за заобљење сталног полупречника између две криве.", + "help.sketch.conic": "Црта конику између две крајње тачке, обликовану теменом и параметром ρ. Користите је за глатке прелазе чију пуноћу желите да подесите, од равног до скоро оштрог угла.", + "help.sketch.constrainer": "Примењује ограничење које једна или две изабране криве већ скоро имају, као што су хоризонтално, паралелно, тангентно или једнако. Користите га да ручно нацртану скицу сређујете ограничење по ограничење.", + "help.sketch.constraint.coincident": "Поставља тачку на другу тачку, криву или осу скице. Користите је да затворите размаке у профилу или да поставите тачку на линију.", + "help.sketch.constraint.collinear": "Поставља две линије на једну праву. Користите је да поравнате прекинуте ивице, на пример обе стране зареза.", + "help.sketch.constraint.concentric": "Поставља два круга или лука на исти центар. Користите је за прстенове, цилиндрична упуштења и вратила са степеницама.", + "help.sketch.constraint.curvature": "Чини да две криве које се сусрећу у крајњој тачки настављају са истом закривљеношћу (G2). Користите је тамо где тангентни спој није довољно глатак, на пример на видљивим површинама.", + "help.sketch.constraint.equal": "Чини линије исте дужине, а кругове и лукове истог полупречника. Користите је да једна кота одређује неколико одговарајућих облика.", + "help.sketch.constraint.fix": "Фиксира тачке или криве где јесу или их поново ослобађа. Користите га за геометрију која се не сме померати када превлачите или решавате скицу.", + "help.sketch.constraint.horizontal_vertical": "Чини линију, или линију између две тачке, хоризонталном или вертикалном, зависно од тога која је ближа. Користите га да обрис поравнате са осама скице.", + "help.sketch.constraint.midpoint": "Поставља тачку на средину линије или чини да се средишње тачке две линије поклопе. Користите га да геометрију центрирате на ивици.", + "help.sketch.constraint.parallel": "Чини да две линије буду паралелне. Користите је за странице које морају остати паралелне када се друге коте промене.", + "help.sketch.constraint.perpendicular": "Чини да се две линије састају под правим углом. Користите је за праве углове између линија које нису на осама скице.", + "help.sketch.constraint.polygon": "Чини затворени ланац линија правилним полигоном: једнаких страница, са сваким теменом на једном кругу. Користите га да ручно нацртани полигон учините правилним и да такав остане.", + "help.sketch.constraint.symmetry": "Чини да две тачке или криве буду једна другој одраз преко линије или око тачке. Користите је да профил остане симетричан када мењате једну страну.", + "help.sketch.constraint.tangent": "Чини да се две криве додирују тангентно, на пример линија и лук. Користите га за глатке спојеве без прегиба.", + "help.sketch.construction": "Пребацује криве између обичне и помоћне геометрије, коју профили изостављају. Користите помоћне криве као помагала за распоред, референце и коте.", "help.sketch.construction_line": "Направите бесконачну помоћну праву кроз две тачке. Користите је да распоредите скицу без раздвајања профила.", "help.sketch.construction_line.angle": "Направите бесконачну помоћну праву под углом од осе X скице. Изаберите тачку и упишите угао или изаберите смер.", "help.sketch.construction_line.bisector": "Изаберите две праве линије скице које се секу, па једну од две симетрале угла. Резултат прати изворне линије.", @@ -1412,11 +1564,71 @@ "help.sketch.construction_line.tangent_external": "Изаберите круг или кружни лук, па спољашњу тачку и решење тангенте. Прегледи приказују само тачке додира на нацртаном луку.", "help.sketch.construction_line.tangent_two": "Изаберите два круга или кружна лука, па у прегледу изаберите заједничку тангенту. Тачке додира морају лежати на нацртаним кривама.", "help.sketch.construction_line.vertical": "Направите бесконачну вертикалну помоћну праву кроз тачку. Њено ограничење вертикалности остаје на снази при каснијим изменама.", + "help.sketch.copy": "Копира изабране криве и тачке скице, заједно са ограничењима и котама међу њима. Налепите их у ову или у другу скицу.", + "help.sketch.create": "Започиње нову 2D скицу на равни или на равном лицу модела. Свака екструзија, ротација и повлачење почиње скицом.", + "help.sketch.cut": "Копира изабрану геометрију скице и брише је из скице, у једном кораку поништавања. Налепите је на другом месту у овој скици или у другу.", + "help.sketch.dimension": "Додаје погонску коту: дужину, растојање, угао, полупречник или пречник, зависно од тога шта изаберете. Упишите вредност или назив параметра, а скица га прати.", + "help.sketch.edit": "Отвара постојећу скицу за измену. Операције изграђене на њој ажурирају се када завршите скицу.", + "help.sketch.ellipse": "Црта елипсу из њеног центра, краја велике осе и тачке на њеној малој оси. Користите је за овалне отворе, брегове и друге издужене округле облике.", "help.sketch.equation_curve": "Црта криву из формула: x(t) и y(t), или y = f(x), у задатом опсегу. Формуле могу да користе параметре и крива их прати: користите је за синусоиду, спиралу или евольвенту зупца зупчаника.", + "help.sketch.extend": "Продужава линију или лук до најближе криве на свом путу или продужава две криве док се не сретну. Користите га да затворите размаке у профилу након одсецања или измене.", + "help.sketch.fillet": "Заобљује угао у којем се састају линије или лукови, луком задатог полупречника. Више углова заобљених заједно деле један полупречник.", + "help.sketch.finish": "Напушта скицу и враћа се на модел. Њени затворени профили су тада спремни за Екструдирај, Ротирај и друге операције.", + "help.sketch.finish_auto_constrain": "Покреће команду Аутоматска ограничења на скици, а затим је завршава. Користите га да једним кораком оставите потпуно дефинисану скицу.", + "help.sketch.fit_curves_to_section": "Пресеца мрежно или пуно тело равни скице и уклапа линије и лукове у пресек. Користите га да скенирани или увезени део поново направите као скицу која се може мењати.", + "help.sketch.include_3d": "Уноси ивице модела, врхове и криве других скица у ову скицу као повезане референце, а оне ван њене равни спљоштава на њу. Користите га да градите на геометрији која не лежи у равни скице.", + "help.sketch.infinite": "Пребацује линије између обичних линија и бесконачних помоћних линија које се пружају преко целог приказа. Користите је за референце распореда које никад не завршавају прерано.", + "help.sketch.insert_dxf": "Учитава криве DXF цртежа у скицу, скалиране, окренуте и постављене како зададете. Користите га да градите на 2D цртежу из другог програма, на пример на обрису за ласерско сечење.", + "help.sketch.insert_svg": "Учитава путање SVG цртежа у скицу, скалиране, окренуте и постављене како зададете. Користите га да логотип или векторски обрис претворите у криве које можете екструдирати.", + "help.sketch.inspect": "Отвара извештај о отвореној скици: колико је степени слободе остало и да ли је потпуно ограничена. Користите га да сазнате шта се још помера пре него што завршите скицу.", + "help.sketch.intersect": "Додаје криве или тачке у којима тела, лица, ивице или друге скице секу раван скице. Оне остају повезане и прате модел када се он промени.", + "help.sketch.intersection_curve": "Прави 3D криве тамо где се два тела или лица састају, повезане са њима. Користите их као путање или водилице које прате модел.", + "help.sketch.isoparametric_curve": "Прави 3D криву дуж U или V смера лица кроз тачку коју изаберете, повезану са лицем. Користите је као путању или линију поделе која прати површ.", + "help.sketch.line": "Црта повезане линије тачку по тачку, као отворени ланац или затворени обрис. То је основни алат за већину профила.", + "help.sketch.line.midpoint": "Црта линију од њене средишње тачке до једног краја, а други крај се пресликава. Користите је за линије центриране на тачки, на пример за симетричну ивицу.", + "help.sketch.line3d": "Црта повезане праве линије између тачака у простору, са укљученом 3D скицом. Користите је за путање и оквире који напуштају раван скице.", + "help.sketch.mirror": "Пресликава криве и тачке скице преко линије или осе и додаје ограничења симетрије. Користите га да нацртате половину симетричног профила и допуните га.", + "help.sketch.move": "Помера или ротира криве и тачке скице, или њихову копију. Користите га да промените положај геометрије уз задржавање њеног облика и ограничења.", + "help.sketch.offset": "Прави копију ланца кривих на задатом растојању, на једној или обе стране. Користите га за зидове, обрисе и зазоре; растојање остаје кота која се може мењати.", + "help.sketch.paste": "Налепљује геометрију скице коју сте копирали или исекли у активну скицу. Поставите је на тачку или је померите за задати помак.", + "help.sketch.pattern.circular": "Копира геометрију скице око централне тачке, преко пуног круга или задатог угла. Користите га за кружне распореде рупа за завртње, паоке и зубе.", + "help.sketch.pattern.rectangular": "Копира геометрију скице у редове и колоне на задатим размацима. Користите га за мреже рупа, жлебова или отвора за проветравање.", + "help.sketch.point": "Поставља тачку скице. Користите је као референцу за коте, ограничења, центре рупа или касније операције.", + "help.sketch.point3d": "Поставља тачку у простору, са укљученом 3D скицом. Користите је као референцу за 3D криве и путање.", + "help.sketch.polygon.circumscribed": "Црта правилан полигон око круга, чија се величина задаје од центра до средине странице. Користите га када је познат отвор кључа, као код шестоугаоне навртке.", + "help.sketch.polygon.edge": "Црта правилан полигон из два краја једне странице. Користите га када су познате дужина и положај странице.", + "help.sketch.polygon.inscribed": "Црта правилан полигон унутар круга, чија се величина задаје од центра до темена. Користите га када је позната мера преко темена.", + "help.sketch.project": "Уноси ивице, лица и врхове модела и криве других скица у ову скицу, пројектоване на њену раван. Оне остају повезане и прате модел када се он промени.", + "help.sketch.project_to_surface": "Пројектује криве других скица дуж нормале ове скице на лице, као повезане 3D криве. Користите га да логотип, линију поделе или путању ставите на закривљену површину.", + "help.sketch.rectangle.center": "Црта правоугаоник из његовог центра и једног угла. Користите га за обрисе центриране на тачки, на пример на исходишту.", + "help.sketch.rectangle.rounded": "Црта правоугаоник са сва четири угла заобљена истим полупречником. Користите га за плоче, поклопце и отворе са заобљеним угловима.", + "help.sketch.rectangle.three_point": "Црта правоугаоник из једне ивице и тачке на супротној страни. Користите га за правоугаонике под углом у односу на осе скице.", + "help.sketch.rectangle.two_point": "Црта правоугаоник из два супротна угла. Користите га за обрисе плоча, блокова и џепова.", + "help.sketch.scale": "Скалира криве и тачке скице задатим фактором око базне тачке. Користите га да промените величину дела скице без поновног цртања.", + "help.sketch.slot.arc_center": "Црта закривљени жлеб задате ширине дуж лука одређеног центром, почетком и крајем. Користите га за жлебове за подешавање око осовине.", + "help.sketch.slot.arc_three_point": "Црта закривљени жлеб задате ширине дуж лука кроз три тачке. Користите га када путања жлеба мора да прође кроз задате тачке.", + "help.sketch.slot.center_point": "Црта прави жлеб задате ширине од његовог центра до центра једног крајњег лука. Користите га за жлебове центриране на тачки.", + "help.sketch.slot.center_to_center": "Црта прави жлеб задате ширине између центара његова два крајња лука. Користите га за жлебове котиране од центра до центра, на пример за жлебове за подешавање завртња.", + "help.sketch.slot.overall": "Црта прави жлеб задате ширине од једног спољног краја до другог. Користите га када је важна укупна дужина жлеба.", + "help.sketch.spline.control_point": "Црта кубни сплајн обликован контролним тачкама кроз које не пролази. Користите га за глатке, течне криве које обликујете померањем тачака.", + "help.sketch.spline.control_point_5": "Црта сплајн степена 5 обликован контролним тачкама. Користите га тамо где се криве морају спојити са глаткијом закривљеношћу, на пример на видљивим дизајнерским површинама.", + "help.sketch.spline.fit_point": "Црта глатки сплајн који пролази кроз сваку тачку коју поставите, отворен или затворен. Користите га да пратите облик кроз познате тачке.", + "help.sketch.spline3d": "Црта глатку криву у простору кроз тачке, са укљученом 3D скицом. Користите га за путање повлачења и трасе каблова који напуштају раван скице.", + "help.sketch.spun_profile": "Прави у равни скице обрис који тело опише када се окреће око осе. Користите га да добијете силуету струганог дела за ротацију или цртеж.", + "help.sketch.text": "Уписује текст у скицу изабраним фонтом, висином и углом; његове речи могу да прате параметре. Екструдирајте га или направите рељеф за ознаке и натписе.", + "help.sketch.transform": "Скалира, окреће и помера геометрију скице око централне тачке у једном кораку. Користите га да истовремено промените положај и величину избора.", + "help.sketch.trim": "Уклања део криве између најближих пресека око места где кликнете. Користите га да преклапајуће линије средите у затворен профил.", "help.solid.align": "Помера изабрана тела тако да се тачка, лице или раван на њима поклопи са тачком, лицем или равни негде другде. Користите га да поставите део уз други без мерења одмака.", + "help.solid.boundary_fill": "Прави тела од области затворених преклапајућим алатним телима, попуњавајући само области које изаберете. Користите је да направите облик од простора који заједно ограничава неколико тела.", + "help.solid.bounding_solid": "Прави кутију која обухвата изабрана тела, са необавезним додатком. Користите га да видите укупну величину дела или као комад материјала од којег се обрађује.", + "help.solid.box": "Прави кутију из дужине, ширине и висине, из угаоне тачке или центрирану на тачки. Користите је за брз блок, привремену замену или тело у које се секу облици.", "help.solid.chamfer": "Одсеца изабране ивице равним скошењем задатог растојања. Користите га да ублажите оштре ивице или олакшате увођење чивије или завртња.", + "help.solid.coil": "Прави завојно тело, на пример опругу, из пречника, броја окретаја, висине или корака и округлог или квадратног пресека. Користите је за опруге, завојнице и завојне гребене.", "help.solid.combine": "Спаја тела, одузима их од циљног тела или задржава само заједничку запремину, а може да задржи и алатна тела. Користите га да спојите делове, да једним обликом одсечете други или да пронађете где се два тела преклапају.", + "help.solid.cone": "Прави конус из основног круга и висине, или зарубљени конус када и врх има полупречник. Користите га за сужења, левкове, упуштања и шиљке.", + "help.solid.cylinder": "Прави цилиндар из полупречника или пречника и висине, постављен на тачку дуж осе. Користите га за вратила, клинове, испусте и округле заготовке.", "help.solid.draft": "Нагиње изабрана лица за угао који задате, мерено од неутралне равни. Користите га на ливеним или бризганим деловима како би изашли из калупа.", + "help.solid.emboss": "Издиже профиле скице, на пример текст или логотип, из лица или их утискује у њега. Користите га за натписе, ознаке и обележја на равним или округлим лицима.", "help.solid.extrude": "Извлачи профил скице у 3D дуж задатог растојања, додајући или уклањајући материјал. Користите га да раван обрис претворите у блок, испуст или џеп.", "help.solid.fillet": "Заобљује изабране ивице глатким кружним прелазом задатог полупречника. Користите га да омекшате оштре ивице ради чврстоће, удобности или изгледа.", "help.solid.hole": "Буши округлу рупу задатог пречника на изабраној тачки лица. Користите је за пролазе завртања, чивије и друге једноставне округле отворе.", @@ -1425,20 +1637,60 @@ "help.solid.move": "Помера или ротира изабрана тела, или њихову копију, за растојање и угао које задате. Користите га да преместите тело или направите друго на другом месту.", "help.solid.offset_face": "Гура изабрана лица унутра или напоље за растојање које задате, а околна лица их прате. Користите га да подебљате зид, продубите џеп или повисите испупчење без мењања скице.", "help.solid.pattern.circular": "Понавља изабране објекте око осе, за задати број и укупни угао. Користите га за кругове завртања, жбице, ребра хладњака и зупце.", + "help.solid.pattern.path": "Копира операције или тела дуж путање скице, према броју и размаку. Користите га за рупе дуж закривљене ивице или делове дуж шине.", "help.solid.pattern.rectangular": "Понавља изабране објекте у једном или два смера са задатим бројем и размаком. Користите га за редове и мреже рупа, ребара, чивија или ножица.", + "help.solid.pipe": "Прави округлу цев дуж путање кривих скице, пуну или шупљу са задатом дебљином зида. Користите је за цеви, жице, каблове и шине.", "help.solid.press_pull": "Додаје или уклања материјал док превлачите лице, или заобљава ивице док их превлачите. Користите га за брзе измене директно на моделу.", "help.solid.remove": "Уклања изабрана тела из дизајна од ове тачке временске траке надаље; ранији кораци их задржавају. Користите га да одбаците помоћно тело, нпр. алат за сечење који вам више не треба.", "help.solid.replace_face": "Премешта изабрана равна лица на раван или на паралелно лице, а тело расте или се смањује да га досегне. Користите га да поравнате лице са другим делом или помоћном равни.", "help.solid.revolve": "Окреће профил скице око осе и прави округло тело. Користите га за вратила, дугмад, посуде и сваки део који је округао око средишње линије.", + "help.solid.rib": "Додаје танак зид дуж отворене криве скице, испуњен до наредних лица тела. Користите га да ојачате зидове и углове, на пример код делова израђених у калупу или штампаних.", "help.solid.scale": "Повећава или смањује изабрана тела за фактор, једнако у свим смеровима или посебно по оси. Користите га да промените величину увезеног облика или урачунате скупљање.", "help.solid.shell": "Издубљује тело и оставља зидове задате дебљине, а може да отвори лица која изаберете. Користите га за кутије, поклопце и друге танкозидне делове.", + "help.solid.sphere": "Прави сферу из полупречника или пречника око централне тачке. Користите је за кугле, дугмад и заобљене крајеве.", "help.solid.split_body": "Сече тело на засебна тела равни, лицем другог тела или линијом скице. Користите га за делове који се засебно штампају, обрађују или боје.", "help.solid.split_face": "Дели изабрана лица по равни, лицу другог тела или кривама скице, без сечења тела. Користите га да делу лица дате сопствени нагиб, боју или одмак.", "help.solid.sweep": "Повлачи профил скице дуж путање и прави тело. Користите га за цеви, шине, ручке и сваки део константног пресека који прати криву.", + "help.solid.thread": "Додаје ISO метрички навој на цилиндрично лице, као козметички навој или урезан у тело. Користите га за завртње, навојне рупе и штампане делове са навојем.", + "help.solid.torus": "Прави прстен из великог полупречника око његове осе и малог полупречника његовог округлог пресека. Користите га за О-прстенове, ручке и округле перле.", + "help.solid.web": "Додаје танак зид дуж сваке отворене криве скице, сваки испуњен до наредних лица тела. Користите је за мрежу потпорних зидова унутар кућишта и тацни.", + "help.surface.blend": "Прави површ која премошћује ивице два тела, тангентно или непрекидно по закривљености са оба. Користите је да глатко спојите две површи преко размака.", + "help.surface.boundary": "Прави површ која испуњава петљу од две до четири криве скице или мрежу кривих. Користите је за слободне панеле дефинисане њиховим ивицама.", + "help.surface.extend": "Продужава површ преко њених ивица, за растојање или до равни или лица. Користите га да се површи преклопе пре него што их одсечете или сашијете.", + "help.surface.extrude": "Извлачи криве или профиле скице дуж растојања у површ, само зидове, без поклопаца. Користите је за танке зидове и за алате за сечење или поделу.", + "help.surface.fair": "Углађује унутрашњост слободних лица док њихове ивице остају на месту. Користите га да изравнате избочине и таласасте одблеске на површи.", + "help.surface.loft": "Прави површ која редом пролази кроз два или више профила, без поклопаца. Користите је за љуске и прелазе између различитих облика.", + "help.surface.match": "Поново гради ивицу површи тако да се поклопи са ивицом другог тела, уз одговарајућу тангентност или закривљеност. Користите га да глатко затворите шав између две површи.", + "help.surface.offset": "Прави копију лица на растојању дуж њихових нормала, као ново површинско тело. Користите је за опне, зазоре или другу страну зида.", + "help.surface.patch": "Испуњава област скице или затворену петљу ивица лицем, равним или глатко закривљеним. Користите је да затворите рупе у површинском телу.", + "help.surface.reverse_normal": "Преврће лица површинског тела, тако да њихова спољна страна гледа на другу страну. Користите га пре подебљавања или сашивања када лица гледају ка унутра.", + "help.surface.revolve": "Окреће криве или профиле скице око осе у површ, без поклопаца. Користите је за округле љуске као што су абажури, посуде и млазнице.", + "help.surface.ruled": "Прави траку од правих линија из ланца ивица, дуж нормале лица, тангенте или под углом. Користите је за зидове са нагибом, прелазе и површи раздвајања.", + "help.surface.stitch": "Шије површинска тела заједно дуж њихових заједничких ивица, у тело када се затворе. Користите га да скуп површи претворите у једно тело.", + "help.surface.sweep": "Помера криве или профиле скице дуж путање у површ, без поклопаца. Користите је за олуке, лајсне и друге танке облике који прате путању.", + "help.surface.thicken": "Даје површинском телу дебљину и претвара га у пуно тело. Користите га да од површи направите делове погодне за штампу или обраду.", + "help.surface.trim": "Сече површ помоћу равни, друге површи или кривих скице и задржава страну коју изаберете. Користите га да површи обликујете сечењем пре сашивања.", + "help.surface.unstitch": "Раздваја лица тела у засебна површинска тела; пуно тело се распада на површи. Користите га да прерадите појединачна лица тела.", + "help.surface.untrim": "Уклања рупе са лица или враћа његову спољну границу на природне границе површи. Користите га да вратите целу површ након одсецања.", "help.third_party": "Лиценце трећих страна", "help.timeline.compute_all": "Поново гради сваку операцију на временској траци од почетка. Користите га да освежите модел и видите које операције не успевају.", "help.timeline.delete": "Брише изабране операције са временске траке; брисање скице брише и операције направљене из ње. Користите га да уклоните корак који више не желите.", + "help.timeline.dependents": "Наводи операције које би биле избрисане заједно са једном операцијом и оне које не би успеле без ње. Користите га пре брисања или промене операције.", "help.timeline.play": "Поново репродукује преостале операције једну по једну, са паузом од пола секунде након сваке обнове. Покрените ову команду поново или притисните Escape да зауставите; Иди на крај одмах скаче на крај.", + "help.view.apply": "Окреће приказ у стандардни смер, као што су предњи, горњи или изометријски, или на сачувани именовани приказ. Користите га да модел гледате из одређеног угла.", + "help.view.delete": "Брише сачувани именовани приказ. Модел се не мења.", + "help.view.fit": "Зумира и помера приказ тако да цео модел, или избор, испуни приказ. Користите га када је модел ван видног поља.", + "help.view.grid": "Приказује или сакрива мрежу у прозору приказа. Користите је као водич за величину и оријентацију током моделовања.", + "help.view.ground_shadow": "Приказује или сакрива сенку модела на подлози. Користите је да лакше процените висину и положај.", + "help.view.home": "Враћа приказ на почетни смер и уклапа модел. Користите га да се брзо вратите на познати приказ.", + "help.view.list": "Наводи именоване приказе сачуване у дизајну. Користите га да пронађете приказ који сте раније сачували.", + "help.view.navigate": "Окреће, помера или зумира приказ по корацима, на пример четвртином окрета улево. Користите га да прецизно померате камеру уместо превлачења.", + "help.view.navigation_bar": "Приказује или сакрива навигациону траку на дну прозора приказа. Сакријте је за више простора, а прикажите је да алатима приказа приступате мишем.", + "help.view.rename": "Даје сачуваном именованом приказу нови назив. Сам приказ остаје исти.", + "help.view.save": "Чува тренутни положај камере као именовани приказ у дизајну. Користите га да се касније вратите на исти угао, на пример за снимке екрана.", + "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": "Потврди брисање", @@ -1615,6 +1867,10 @@ "material.selection.mixed": "Различити материјали", "measure.at": "На", "measure.item": "Ставка {number}", + "measure.quick_copied": "Мерење је копирано", + "measure.quick_copy": "Кликните да копирате мерење", + "measure.quick_edges": "Ивица: {count}", + "measure.quick_faces": "Лица: {count}", "messages.ago_h": "пре {n} h", "messages.ago_min": "пре {n} min", "messages.ago_s": "пре {n} s", @@ -1767,6 +2023,11 @@ "not a move": "није померање", "not a number": "није број", "open a sketch first": "прво отворите скицу", + "parameters.apply_live": "Примени сваку измену чим напустите њено поље", + "parameters.apply_live_tip": "Искључено: измене чекају на Примени или У реду, а затим се дизајн једном поново гради за све њих. Откажи или Esc их одбацује.", + "parameters.held": "Измена на чекању за Примени: {count}", + "parameters.held_hint": "Мењајте изразе и јединице. Измене чекају на Примени или У реду.", + "parameters.held_one": "1 измена на чекању за Примени", "parts.automatic_name": "аутоматски", "parts.dimension.across_flats": "Отвор кључа", "parts.dimension.bore": "Отвор", @@ -1789,6 +2050,8 @@ "parts.family.set_screw": "Завртањ без главе са унутрашњим шестоугаоником, равног врха", "parts.family.socket_head_cap_screw": "Завртањ са цилиндричном главом и унутрашњим шестоугаоником", "parts.family.washer": "Обична подлошка", + "parts.holes": "Рупе (необавезно)", + "parts.placement_hint": "изаберите једну или више рупа да у сваку поставите део, или У реду поставља део у исходиште", "pattern.alignment_2": "Поравнање 2", "pattern.alignment_hint": "Број укључује оригинал. Центар равномерно распоређује копије; Обрни бира додатну страну за парне укупне бројеве.", "pattern.direction_1": "Смер 1", @@ -1955,6 +2218,8 @@ "prefs.origin_plane_names": "Називи равни исходишта", "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.persistent_ribbon_pressed": "Истакни активне команде на траци", "prefs.persistent_ribbon_pressed_help": "Истиче команде на траци док су њихови ефекти укључени. Искључите да бисте задржали претходни неутрални изглед и помоћ за команде.", "prefs.perspective_fov": "Видно поље", @@ -1984,6 +2249,8 @@ "prefs.quality_fastest": "најбрже", "prefs.quality_slowest": "најспорије", "prefs.quality_smoother": "глађе", + "prefs.quick_measure": "Брзо мерење у углу приказа", + "prefs.quick_measure_note": "Величина избора у доњем десном углу приказа: дужина ивице, или Ø и R за округлу, површина лица, положај тачке, растојање и угао две ставке, збир неколико ивица или лица. Клик га копира.", "prefs.render_previous_geometry_budget": "Користи претходни буџет геометрије за преглед у Рендеровању", "prefs.render_previous_geometry_budget_help": "Враћа претходно фиксно ограничење геометрије (512 MiB на 64-битним десктоп рачунарима, 128 MiB другде) уместо буџета према уређају.", "prefs.reports_saved": "Сачувано: {n}", @@ -2029,6 +2296,8 @@ "prefs.sketch_tab_always_note": "Картица Скица остаје на траци, одмах после картице Модел, док ниједна скица није отворена. Њени алати за цртање тада започињу скицу на изабраној равни или лицу, или на равни коју следећу изаберете, са спремним алатом у њој.", "prefs.sketch_tracking_guides": "Водичи за поравнање центра и средишње тачке", "prefs.sketch_tracking_guides_note": "Задржите показивач изнад центра круга или средишта праве ивице, па се померите хоризонтално или вертикално да би се приказао привремени тачкасти водич.", + "prefs.sketch_tracking_points": "Водичи за поравнање од крајњих тачака и углова током цртања", + "prefs.sketch_tracking_points_note": "Док алат за цртање чека на тачку, задржавање показивача изнад краја линије или лука, угла правоугаоника или слободне тачке приказује исти привремени тачкасти водич као код центра или средишње тачке. Искључено: само центри и средишње тачке.", "prefs.sketching": "Скицирање", "prefs.snap_drawing_note": "Током цртања или превлачења тачке скице.", "prefs.source": "Извор", @@ -2056,6 +2325,16 @@ "prefs.theme_follow_off": "Искључено", "prefs.theme_follow_stop": "Престани да пратиш", "prefs.theme_import_hint": "Кликните да изаберете датотеку теме, превуците је овде или притисните Ctrl+V (Cmd+V на Mac рачунару) када је тема копирана", + "prefs.theme_option_opacity": "Непрозирност", + "prefs.theme_option_opacity_help": "Подесите непрозирност плутајућих панела. Доња граница читљивости штити текст преко боја прозора приказа.", + "prefs.theme_option_tab_colours": "Боје картица", + "prefs.theme_option_tab_colours_help": "Користи боје панела и прозора приказа ове теме за сваку картицу на траци.", + "prefs.theme_option_tab_fade_ms": "Прелаз између картица", + "prefs.theme_option_tab_fade_ms_help": "Постепено мења боје панела и прозора приказа при промени картице. Нула прелази одмах.", + "prefs.theme_options_badge": "Опције", + "prefs.theme_options_more": "Више · {count}", + "prefs.theme_options_reset": "Ресетуј", + "prefs.theme_options_unavailable": "Није доступно на овом уређају", "prefs.theme_paste": "Налепи тему", "prefs.theme_paste_none": "Клипборд не садржи SolidCraft тему", "prefs.timeline_half_hour": "−30 min", @@ -2077,6 +2356,8 @@ "prefs.under_cursor": "Испод показивача", "prefs.units_precision": "Јединице и прецизност", "prefs.updates": "Ажурирања", + "prefs.value_boxes_in_view": "Прикажи вредности поред њихових ручица у приказу", + "prefs.value_boxes_in_view_tip": "Вредност команде (Растојање, Полупречник…) у пољу поред њене ручице, преко модела. Искључено: уносите је у прозору команде, који преузима тастатуру након избора, а ништа не прекрива модел.", "prefs.view": "Понашање приказа", "prefs.view_confirm_corner": "У реду и Откажи у углу приказа", "prefs.view_confirm_corner_note": "Док је отворен дијалог или скица, квачица и крстић горе десно у приказу раде исто што и дугмад У реду и Откажи у дијалогу, односно завршавају и отказују скицу. Навигатор приказа помера се испод њих.", @@ -2086,6 +2367,12 @@ "prefs.viewport_background": "Позадина", "prefs.viewport_background_colour": "Боја", "prefs.viewport_background_theme": "Градијент теме", + "prefs.viewport_section_status": "Прикажи статус пресека у прозору приказа", + "prefs.viewport_section_status_help": "Приказује подешавање пресека у прозору приказа. Esc и активно дугме на траци га искључују. Искључите да задржите претходне контроле пресека.", + "prefs.viewport_status_details": "Детаљи прозора приказа уживо", + "prefs.viewport_status_details_help": "Мењајте подешавања активне анализе и пресека у стандардним картицама са детаљима. Искључите да вратите легенде анализе само за читање и дијалог Пресек.", + "prefs.viewport_status_summary": "Сажми ставке статуса у прозору приказа", + "prefs.viewport_status_summary_help": "Сажима три или више ставки статуса у прозору приказа у једну збирну значку. Искључите да свака ставка остане у пуном реду.", "prefs.wheel": "Точкић", "prefs.wheel_trackpad": "Точкић и додирна табла", "prefs.workspace": "Радни простор", @@ -2964,7 +3251,9 @@ "render.output_ready": "Спремно за рендеровање", "render.output_run_check": "Покрени проверу", "render.output_size_limit": "{method}: до {dimension} px по страни и {pixels} пиксела. Највећа величина која стаје: {width} × {height}.", + "render.output_size_method_profile": "Да бисте рендеровали {width} × {height}, изаберите {method} са CPU профилом ресурса {profile}, или мању величину.", "render.output_size_ok": "Величина је у реду · {width} × {height} px", + "render.output_size_profile": "Да бисте рендеровали {width} × {height}, изаберите CPU профил ресурса {profile}, или мању величину.", "render.output_swap_orientation": "Замени ширину и висину", "render.output_tools_inspector": "Величину и формат измените у инспектору Излаз.", "render.output_view_image": "Прикажи слику", @@ -3177,6 +3466,7 @@ "render.size_custom": "Прилагођено…", "render.size_full_hd": "Full HD", "render.size_hd": "HD", + "render.size_needs_profile": "{size} · захтева {profile}", "render.size_presets": "Унапред подешене величине", "render.size_presets_pending": "Стиже са уређивачем излаза", "render.size_preview": "Преглед", @@ -3464,6 +3754,24 @@ "sheet.flange_type_contour": "Контура", "sheet.flange_type_edge": "Ивица", "sheet.no_body": "нема лименог тела: направите га помоћу Прирубница > Основа или претворите плочу у лим помоћу Претвори у лим (изаберите њено велико равно лице)", + "sheet.personal_rules.confirm_delete": "Потврди брисање", + "sheet.personal_rules.delete": "Избриши", + "sheet.personal_rules.delete_prompt": "Избрисати ово лично правило? Правила која су већ у дизајнима остају непромењена.", + "sheet.personal_rules.full": "Лична библиотека већ садржи 64 правила.", + "sheet.personal_rules.invalid_name": "Унесите кратак назив без прелома реда или контролних знакова.", + "sheet.personal_rules.invalid_values": "Допуните вредности правила: свака мора да се израчуна у овом дизајну, са позитивном дебљином и K-фактором од 0 до 1.", + "sheet.personal_rules.missing": "Изабрано лично правило више није доступно.", + "sheet.personal_rules.name": "Назив правила", + "sheet.personal_rules.name_exists": "Лично правило са овим називом већ постоји. Користите други назив.", + "sheet.personal_rules.name_hint": "Јединствен назив", + "sheet.personal_rules.overwrite": "Сачувај промене у изабраном личном правилу", + "sheet.personal_rules.rename": "Преименуј", + "sheet.personal_rules.rule": "Лично правило", + "sheet.personal_rules.save": "Сачувај ново", + "sheet.personal_rules.scope": "Сачувано у вашем профилу за све дизајне.", + "sheet.personal_rules.select": "Изаберите правило", + "sheet.personal_rules.title": "Лична правила", + "sheet.personal_rules.unsupported": "Ово правило има вредности које ова верзија не може да користи.", "shortcut.json_error": "параметри морају бити JSON: {error}", "shortcut.no_command": "нема команде `{id}`; текст пре JSON-а мора бити id команде", "shortcut.pin": "Прикачи на врх S оквира", @@ -3500,6 +3808,7 @@ "sketch_text.align_right": "Десно", "sketch_text.bold": "Подебљано", "sketch_text.builtin_font": "{family} (уграђено)", + "sketch_text.formula": "Почните са = да речи саставите од параметара: ='W' + number(Width)", "sketch_text.italic": "Курзив", "sketch_text.loading_fonts": "Тражење инсталираних фонтова…", "sketch_text.on_path": "Текст прати своју путању: крива га поставља", @@ -3640,6 +3949,8 @@ "ui.3d_mouse_section": "3D миш", "ui.3d_mouse_show_in_view": "Прикажи 3D миш у 3D приказу", "ui.3d_mouse_show_in_view_tip": "Слика вашег 3D миша у доњем левом углу, која се креће док га померате.", + "ui.3d_mouse_sketch_orbit": "Окрећи скице 3D мишем", + "ui.3d_mouse_sketch_orbit_tip": "3D миш окреће приказ и док је скица отворена и гледана под правим углом, као што то може и миш. Искључено: помера и зумира скицу док не нагнете приказ.", "ui.3d_mouse_skip": "Мој уређај ово не може", "ui.3d_mouse_skip_tip": "Прескаче ово кретање у оба смера, а калибрација га изоставља.", "ui.3d_mouse_speed": "Брзина 3D миша", @@ -3708,6 +4019,7 @@ "ui.agent_chat_auth_terminal": "Покрените ово у терминалу, а затим притисните Повежи поново:", "ui.agent_chat_auth_title": "Пријави се код агента", "ui.agent_chat_cancel": "Откажи", + "ui.agent_chat_cannot_start": "Инсталиран, али се не покреће: {error}", "ui.agent_chat_changes": "Промене: {n}, један корак поништавања", "ui.agent_chat_changes_n": "Промене: {n}", "ui.agent_chat_changes_of": "Промене: {n}/{limit}", @@ -3800,9 +4112,12 @@ "ui.agent_chat_queued_stopped": "У реду чекања (заустављено, није послато)", "ui.agent_chat_refresh": "Освежи", "ui.agent_chat_refused": "Није дозвољено агенту: {reason}", + "ui.agent_chat_reinstall": "{agent} је инсталиран ({program}), али се није покренуо када га је SolidCraft проверио: {error}\nЊегова инсталација је можда непотпуна или направљена за другу врсту рачунара. Инсталирајте га поново помоћу `{install}`, проверите да у терминалу исписује своју верзију, а затим притисните Освежи.", "ui.agent_chat_remove": "Уклони из реда", "ui.agent_chat_remove_file": "Скини са ове поруке", "ui.agent_chat_remove_imported": "Скини са ове поруке; део остаје у дизајну (Поништи га уклања)", + "ui.agent_chat_reply_cut_off": "Одговор је достигао ограничење дужине пре него што је завршен, па је можда непотпун. Затражите од агента да настави или да одговори сажетије.", + "ui.agent_chat_reply_declined": "Модел је одбио да одговори на ову поруку.", "ui.agent_chat_retry": "Покушај поново", "ui.agent_chat_right": "Десно", "ui.agent_chat_save": "Сачувај", @@ -3861,12 +4176,17 @@ "ui.ai_copy_suffix": "копирај", "ui.ai_custom_url": "Прилагођени URL", "ui.ai_default": "Подразумевано", + "ui.ai_edit": "Измени", + "ui.ai_edit_agent_title": "Измени агента", + "ui.ai_edit_gone": "Овај агент је уклоњен, па нема шта да се сачува.", + "ui.ai_edit_tip": "Промените назив, команду или крајњу тачку овог агента. Сачувани кључ остаје скривен.", "ui.ai_http_busy": "крајња тачка је заузета или је преко ограничења; покушајте поново касније", "ui.ai_http_key": "крајња тачка је одбила кључ: проверите га у Подешавања > AI", "ui.ai_http_not_found": "нема такве крајње тачке или модела: проверите основни URL и назив модела", "ui.ai_http_too_large": "разговор је превелик за ову крајњу тачку; започните нови разговор", "ui.ai_key": "API кључ", "ui.ai_key_hint": "необавезно; чува се у системском привеску кључева", + "ui.ai_key_keep_hint": "оставите празно да задржите сачувани кључ", "ui.ai_key_needs_https": "Кључ се шаље само преко HTTPS-а или на овај рачунар.", "ui.ai_key_note": "Чува се у системском привеску кључева, никад у подешавањима SolidCraft-а. За локални сервер оставите празно.", "ui.ai_keychain_error": "Системски привесак кључева је одбио: {error}", @@ -3898,6 +4218,7 @@ "ui.ai_request_timeout": "Временско ограничење захтева", "ui.ai_request_timeout_hint": "Колико дуго један захтев моделу на овој крајњој тачки сме да траје, од 30 s до једног сата. Повећајте за спори локални модел.", "ui.ai_reset": "Ресетуј", + "ui.ai_save": "Сачувај", "ui.ai_settings": "AI подешавања…", "ui.ai_show_features": "Прикажи AI функције", "ui.ai_show_features_note": "Разговор са агентом: панел у ком агент за кодирање који већ користите или крајња тачка модела коју изаберете ради на отвореном дизајну. Искључено: нема AI дугмета, пречице, ставке менија ни панела, и ништа се не тражи нити покреће.", @@ -4071,6 +4392,40 @@ "ui.design_properties.project": "Пројекат", "ui.design_properties.revision": "Ревизија", "ui.design_saved": "Сачувано: {path}", + "ui.details_access_caption": "Лица показују да ли алат може да допре до њих из изабраног смера.", + "ui.details_angle": "Угао", + "ui.details_continuity_caption": "Измените изабрани шав. Остали шавови задржавају своја подешавања.", + "ui.details_continuity_stale": "Старија геометрија: поново покрените анализу да бисте изменили овај шав или га уклопили у приказ.", + "ui.details_copy_list": "Копирај списак", + "ui.details_curvature": "Толеранција закривљености", + "ui.details_curvature_caption": "Лица су обојена према локалном полупречнику закривљености.", + "ui.details_direction": "Смер алата", + "ui.details_draft_caption": "Лица су обојена према углу у односу на смер вађења из калупа.", + "ui.details_edit_seam": "Измени шав", + "ui.details_fixture": "Узорак компоненте — само пробни подаци", + "ui.details_fixture_caption": "Узорак компоненте са илустративним вредностима; ово није резултат модела.", + "ui.details_fixture_problem": "Пример налаза са проблемом", + "ui.details_fixture_readout": "Пример измерене вредности", + "ui.details_flip": "Обрни", + "ui.details_gap": "Размак", + "ui.details_hatch": "Шрафирај пресечена лица", + "ui.details_jump": "Скок закривљености", + "ui.details_key": "Легенда", + "ui.details_offset": "Одмак", + "ui.details_plane": "Раван", + "ui.details_pull": "Смер вађења из калупа", + "ui.details_radius": "Полупречник", + "ui.details_readout": "Очитавање", + "ui.details_samples": "Узорци", + "ui.details_seam": "{body} · Ивица {edge}", + "ui.details_settings": "Подешавања", + "ui.details_sharp_gap": "Оштро или са размаком", + "ui.details_show_all": "Прикажи свих {count}", + "ui.details_smooth": "Глатко", + "ui.details_stripes": "Пруге", + "ui.details_target": "Уклопи геометрију", + "ui.details_worst_seams": "Најгори шавови", + "ui.details_zebra_caption": "Одбијене пруге показују колико се глатко лица спајају.", "ui.diameter": "Пречник", "ui.dimension": "Кота", "ui.direct_interaction": "Директна интеракција", @@ -4131,6 +4486,7 @@ "ui.equation_y_of_x": "y(x)", "ui.equivalent_radius": "Еквивалентни полупречник", "ui.expand": "Прошири", + "ui.explode_needs_two_components": "Растављени приказ раздваја компоненте, па дизајн мора имати две или више њих (Нова компонента, Уметни дизајн или Направи компоненте из тела)", "ui.exportDxf": "Извези DXF…", "ui.export_ellipsis_keymap": "Извези…", "ui.export_keymap_tip": "Тастери, омиљено из оквира S и трака са алатима, као датотека", @@ -4419,12 +4775,17 @@ "ui.plugins_skipped": "Фасцикле није било могуће прочитати: {count}.", "ui.plugins_version": "{version}", "ui.point": "Тачка", + "ui.point_cloud_add_filter": "Додај филтер…", "ui.point_cloud_auto_range": "Аутоматски опсег", "ui.point_cloud_color_by": "Боја према", "ui.point_cloud_file_colors": "Боје из датотеке", + "ui.point_cloud_filter_by": "Филтрирај према", "ui.point_cloud_height_high": "Највише", "ui.point_cloud_height_low": "Најниже", + "ui.point_cloud_legend_filter": "Приказује се и сакрива у легенди изнад", "ui.point_cloud_no_file_colors": "Датотека нема боје", + "ui.point_cloud_pause": "Паузирај", + "ui.point_cloud_play": "Репродукуј", "ui.point_cloud_single_color": "Једна боја", "ui.point_cloud_size": "Величина тачке", "ui.point_cloud_units": "Јединице датотеке", @@ -4453,6 +4814,7 @@ "ui.properties.inertia_y": "Iyx, Iyy, Iyz", "ui.properties.inertia_z": "Izx, Izy, Izz", "ui.properties.measuring": "Мерење…", + "ui.properties.measuring_bodies": "Мерење… {done} од {total} тела", "ui.properties.measuring_failed": "Мерење је заустављено пре завршетка.", "ui.properties.principal_axis": "Главна оса {number}", "ui.properties.principal_moments": "Главни моменти", @@ -4520,6 +4882,7 @@ "ui.selectOther": "Изабери друго…", "ui.selectTangentChain": "Изабери тангентни ланац", "ui.select_all": "Изабери све", + "ui.select_components_by_face": "Изабери компоненте (лице бира своју компоненту)", "ui.selection": "Избор", "ui.selection_modifiers": "Модификатори избора", "ui.selection_shift_add_only": "Shift+клик додаје без уклањања", @@ -4561,6 +4924,11 @@ "ui.sketch_intersect_selection_filter": "Филтер избора", "ui.sketch_modifiers": "Модификатори скице", "ui.sketch_project_geometry": "Геометрија", + "ui.sketch_report.all_constrained": "Све је потпуно ограничено.", + "ui.sketch_report.dof": "Степени слободе", + "ui.sketch_report.loose": "Није потпуно ограничено", + "ui.sketch_report.status": "Статус", + "ui.sketch_report.title": "Извештај о скици", "ui.sketch_scale": "Скалирање скице", "ui.sketches": "Скице", "ui.slice": "Пресеци", @@ -4670,7 +5038,14 @@ "ui.viewport_status_key": "Прикажи легенду за {name}", "ui.viewport_status_minimum_radius": "Минимални полупречник", "ui.viewport_status_pairs": "Парова: {count}", + "ui.viewport_status_section_hatch": "{setting} · шрафура", + "ui.viewport_status_section_plane": "Тренутна раван", + "ui.viewport_status_section_tilted": "нагнуто · {offset}", "ui.viewport_status_stripes": "Пруга: {count}", + "ui.viewport_status_summary_clear": "Искључи све", + "ui.viewport_status_summary_heading": "У овом приказу", + "ui.viewport_status_summary_label": "{name} и још ставки: {count}", + "ui.viewport_status_summary_more": "+{count}", "ui.viewport_theme_follow": "Прати тему", "ui.visual_shaded_hidden_edges": "Осенчено са скривеним ивицама", "ui.visual_shaded_with_edges": "Осенчено са ивицама", diff --git a/assets/locales/sv.json b/assets/locales/sv.json index 46e0a79870..76d115e073 100644 --- a/assets/locales/sv.json +++ b/assets/locales/sv.json @@ -60,6 +60,7 @@ "BLEND SURFACE": "BLANDYTA", "BOUNDARY SURFACE": "GRÄNSYTA", "BROWSER": "WEBBLÄSARE", + "Back to the original order": "Tillbaka till ursprunglig ordning", "Ball": "Kul", "Bar": "Stång", "Base Center": "Bascentrum", @@ -595,6 +596,8 @@ "Snap to face and edge centres": "Snappa till ytors och kanters centrum", "SolidCraft cannot submit reports directly. Submit on GitHub; no token is needed in the app.": "SolidCraft kan inte skicka in rapporter direkt. Skicka in dem på GitHub; ingen token behövs i appen.", "SolidCraft closed before these designs were saved. Their last autosave can be opened.": "SolidCraft stängdes innan dessa konstruktioner sparades. Deras senaste autospar kan öppnas.", + "Sort A to Z": "Sortera A till Ö", + "Sort Z to A": "Sortera Ö till A", "Spacing 2 must be a non-zero number": "Avstånd 2 måste vara ett tal skilt från noll", "Spacing must be a non-zero number": "Avståndet måste vara ett tal skilt från noll", "Specialise": "Specialisera", @@ -819,6 +822,7 @@ "cmd.appearance_pick_up": "Plocka upp utseende", "cmd.appearance_replace": "Ersätt utseende", "cmd.assembly_bom": "Stycklista", + "cmd.assembly_mass_properties": "Massegenskaper", "cmd.body.print_face": "Utskriftsyta", "cmd.body_rename": "Byt namn på kropp", "cmd.browser_collapse_all": "Fäll ihop alla", @@ -1244,6 +1248,7 @@ "cmd.view.render_browser": "Renderingswebbläsare", "cmd.view.render_dock": "Flytta renderingspanel", "cmd.view.render_focus": "Fokusvy för rendering", + "cmd.view.status_details": "Statusdetaljer i vyfönstret", "cmd.view.status_items": "Statusobjekt i vyfönstret", "cmd.view_ambient_occlusion": "Omgivningsocklusion", "cmd.view_angle_readout": "Vyvinklar", @@ -1387,21 +1392,168 @@ "from the rule": "från regeln", "help.apache": "Apache-licens 2.0", "help.appearance.assign": "Ger de markerade kropparna, ytorna eller komponenterna en färg eller ett materialutseende, utan att ändra deras vikt. Använd det för att skilja delar åt eller förhandsgranska en ytfinish.", + "help.assembly.bom": "Listar delarna i konstruktionen eller i en komponent med deras antal, per artikelnummer, och kan spara listan som CSV eller JSON. Använd den som stycklista för beställning och montering.", "help.attributions": "Erkännanden (ATTRIBUTION.md)", "help.build": "Version {version} · commit {commit} · byggd {date}", + "help.canvas.decal": "Placerar en bild på en plan yta på modellen, som ett klistermärke. Använd det för att visa en etikett, en logotyp eller ett tryckt mönster på en del.", + "help.canvas.edit": "Ändrar en duk: dess läge, storlek, vinkel, opacitet, spegling, synlighet eller namn. Använd det för att passa in en referensbild mot skissen som du ritar av den i.", + "help.canvas.insert": "Placerar en PNG- eller JPEG-bild på ett plan eller en yta som en genomskinlig referens. Använd det för att rita av ett foto eller en ritning i en skiss, med bilden skalad till en känd storlek.", + "help.component.break_link": "Kopplar loss en länkad komponent från sin konstruktionsfil och behåller dess kroppar som de är. Använd det när delen inte längre ska följa ändringar i originalfilen.", + "help.component.capture_position": "Behåller lägena för delar som du flyttat för hand, så att de blir delarnas nya utgångsläge. Använd det efter att du dragit delar på plats som ingen led håller fast.", + "help.component.create": "Lägger till en ny, tom komponent i konstruktionen, i den aktiva komponenten om du inte väljer en annan överordnad komponent. Använd det för att ge en ny del av en sammanställning en egen plats för kroppar och skisser.", "help.component.finish_edit": "Sparar delen du redigerar på plats, återgår till dess sammanställning och uppdaterar sammanställningen utifrån den. Använd det när dina ändringar i den länkade delen är klara.", + "help.component.from_bodies": "Gör varje markerad kropp till en egen komponent, uppkallad efter kroppen. Använd det när en konstruktion byggd av lösa kroppar ska bli en sammanställning vars delar kan fogas samman med leder och flyttas.", + "help.component.insert_design": "Infogar en annan sparad konstruktion som en komponent länkad till sin fil, med dess solida kroppar placerade som de är där. Använd det för att återanvända en del i en sammanställning och få med senare ändringar via Uppdatera länkar.", + "help.component.properties": "Visar volym, massa, masscentrum och tröghetsmoment för en komponent, utifrån kropparnas material. Använd det för att kontrollera vikt och balans hos en sammanställning.", + "help.component.revert_position": "Sätter tillbaka delar som du flyttat för hand där de var och förkastar flyttningar som inte fångats. Använd det för att ångra provflyttningar i en sammanställning.", + "help.component.update_links": "Läser de länkade konstruktionsfilerna igen och ersätter kroppar som har ändrats. Använd det efter att du redigerat en del som den här sammanställningen använder.", + "help.config.configure": "Lägger till parametrar, funktioner, material eller utseenden som kolumner i konfigurationstabellen. Använd det för att bygga varianter av en konstruktion, till exempel storlekar på en del.", + "help.config.preview": "Kontrollerar att en rad i konfigurationstabellen går att bygga om, utan att ändra konstruktionen eller dess historik. Använd det innan du byter till en variant som du är osäker på.", + "help.config.rules": "Visar konfigurationstabellens regler och rader och vilken rad som är aktiv. Använd det för att se hur en konstruktions varianter är uppsatta.", + "help.config.table": "Visar konfigurationstabellen: en rad per variant, en kolumn per parameter eller funktion, och om konstruktionen fortfarande stämmer med den aktiva raden. Använd det för att granska en konstruktions varianter.", "help.connect_note": "Starta om varje assistent för att läsa in SolidCraft-servern. Varje fil säkerhetskopierades som .bak innan den ändrades.", "help.connect_title": "Anslut AI-assistenter", + "help.construct.axis.edge": "Skapar en konstruktionsaxel längs en rak kant eller skisslinje. Använd den som axel för en rotation, ett cirkulärt mönster eller en led.", + "help.construct.axis.normal_to_face": "Skapar en konstruktionsaxel genom en punkt, vinkelrät mot en yta. Använd den som en axel som står rakt ut från en yta.", + "help.construct.axis.through_cylinder": "Skapar en konstruktionsaxel genom centrum av en cylinder-, kon- eller torusyta, eller en cirkulär kant. Använd den för att rotera eller mönstra funktioner runt en rund del.", + "help.construct.axis.two_planes": "Skapar en konstruktionsaxel där två plan eller plana ytor möts. Använd den när den axel du behöver är linjen mellan två ytor.", + "help.construct.axis.two_points": "Skapar en konstruktionsaxel genom två punkter, hörn eller skisspunkter. Använd den för en axel som löper mellan två kända lägen.", + "help.construct.plane.along_path": "Skapar ett konstruktionsplan vinkelrätt mot en kant eller kurva, på en position längs den. Använd det för att skissa en profil för en svepning eller ett tvärsnitt av en bana.", + "help.construct.plane.at_angle": "Skapar ett konstruktionsplan som är vridet i en vinkel från ett plan eller en yta, runt en axel eller kant. Använd det för lutande skisser, till exempel ett vinklat fäste.", + "help.construct.plane.midplane": "Skapar ett konstruktionsplan mitt emellan två plan eller plana ytor, eller som delar vinkeln där de möts i en vinkel. Använd det som symmetriplan vid spegling.", + "help.construct.plane.offset": "Skapar ett konstruktionsplan parallellt med ett plan eller en plan yta, på ett avstånd eller genom en punkt. Använd det för att skissa på en bestämd höjd över en yta.", + "help.construct.plane.perpendicular": "Skapar ett konstruktionsplan som innehåller en linje eller kant och står vinkelrätt mot ett plan eller en yta. Använd det för att skissa upprätt på en modell längs en kant.", + "help.construct.plane.tangent": "Skapar ett konstruktionsplan som tangerar en cylinder, kon, sfär eller torus i en punkt. Använd det för att skissa på sidan av en rund del.", + "help.construct.plane.three_points": "Skapar ett konstruktionsplan genom tre punkter. Använd det för ett plan som bestäms av lägen på modellen, till exempel tre hörn.", + "help.construct.plane.two_edges": "Skapar ett konstruktionsplan genom två raka kanter eller linjer som ligger i samma plan. Använd det för ett plan tvärs genom en del, till exempel genom två motstående kanter.", + "help.construct.point.along_path": "Skapar en konstruktionspunkt på en position längs en kant eller kurva. Använd den som referens en bit längs en bana.", + "help.construct.point.center": "Skapar en konstruktionspunkt i centrum av en cirkulär kant, en skisscirkel eller skissbåge, en sfär eller en torus. Använd den som referens för hål, axlar och leder.", + "help.construct.point.edge_plane": "Skapar en konstruktionspunkt där en kant eller linje går genom ett plan eller en plan yta. Använd den för att markera var en bana korsar ett plan.", + "help.construct.point.three_planes": "Skapar en konstruktionspunkt där tre plan eller plana ytor möts. Använd den som hörnreferens, även där modellen saknar hörn.", + "help.construct.point.two_edges": "Skapar en konstruktionspunkt där två kanter eller linjer möts eller kommer närmast varandra. Använd den som referens i ett virtuellt hörn.", + "help.construct.point.vertex": "Skapar en konstruktionspunkt på ett hörn eller en skisspunkt. Använd den som en referens som finns kvar när skissen eller kroppen runt den är dold.", + "help.contact.check": "Listar de par av delar i kontaktuppsättningar som går igenom varandra i sitt nuvarande läge. Använd det för att hitta kollisioner innan du driver leder eller kör en rörelsestudie.", + "help.contact.create": "Grupperar två eller fler delar i en kontaktuppsättning vars kroppar hindras från att gå igenom varandra medan lederna rör sig. Använd det för delar som ska stanna när de rör vid varandra, till exempel en spak mot sitt stopp.", + "help.contact.disable_all": "Stänger av kontakt, så att drivna leder och rörelsestudier inte längre stannar där delar rör vid varandra. Använd det för att kunna flytta delar fritt igen, eller för att hålla en stor sammanställning responsiv.", + "help.contact.edit": "Ändrar en kontaktuppsättning: vilka delar den innehåller, dess namn eller om den är undertryckt; den kan också ta bort uppsättningen. Använd det för att justera vilka delar som hindras från att gå igenom varandra.", + "help.contact.enable_all": "Lägger alla delar i sammanställningen i en enda kontaktuppsättning, så att inga två delar kan gå igenom varandra medan lederna rör sig. Använd det för en snabb kontroll av en hel mekanism.", + "help.contact.enable_sets": "Slår på kontakt för de kontaktuppsättningar du har definierat, så att drivna leder och rörelsestudier stannar där deras delar skulle gå igenom varandra. Använd det för att få tillbaka dina uppsättningar efter att du stängt av kontakt.", + "help.contact.list": "Listar konstruktionens kontaktuppsättningar, delarna i var och en och om kontakt är på. Använd det för att kontrollera hur kontakt är inställd innan du driver sammanställningen.", + "help.document.inspect": "Visar en översikt över konstruktionen: dess komponenter, kroppar, skisser, funktioner och parametrar. Använd det för att orientera dig i en konstruktion som du inte byggt själv.", + "help.document.units": "Ändrar enheterna som konstruktionen visar längder i, till exempel mm eller tum. Geometrin behåller sin storlek; bara visningen och tal utan enhet följer med.", + "help.edit.checkpoint": "Kommer ihåg konstruktionen, den aktiva skissen och ångrahistoriken som de är nu. Använd det innan du provar något riskabelt som du kanske vill gå tillbaka från.", + "help.edit.restore_checkpoint": "Går tillbaka till en kontrollpunkt, inklusive ångrahistoriken. Använd det för att förkasta allt som gjorts sedan kontrollpunkten i ett enda steg.", + "help.explode.create": "Skapar en sprängvy som skjuter delarna bort från sammanställningens centrum, eller enligt de förflyttningar du anger. Använd det för att visa hur en sammanställning sätts ihop, för instruktioner eller presentationer.", "help.face.delete": "Tar bort de markerade ytorna och låter angränsande ytor sluta hålet, eller lämnar hålet öppet som en yta. Använd det för att ta bort en avrundad kant, ett hål eller ett nav som är i vägen.", + "help.file.insert_mesh": "Lägger till en 3MF-, STL-, OBJ-, PLY- eller glTF-fil i konstruktionen som meshkroppar i en ny komponent. Använd det för att placera en nedladdad eller skannad modell bredvid din konstruktion som referens.", + "help.file.insert_step": "Lägger till en STEP-, IGES-, FreeCAD- eller Rhino-fil i konstruktionen som solida kroppar i en ny komponent; en sammanställnings delar blir nästlade komponenter. Använd det för att hämta in modeller från leverantörer eller andra CAD-program.", + "help.file.save_mesh": "Sparar kroppar som mesh i formatet STL, 3MF eller OBJ, med den jämnhet för krökta ytor som du väljer. Använd det för att förbereda delar för 3D-utskrift eller för program som läser mesh.", + "help.file.send_to_slicer": "Sparar de markerade kropparna som en 3MF-fil och öppnar den i den slicer du väljer. Använd det för att gå från modell till utskrift i ett enda steg.", "help.font_details": "Gränssnittet använder egui:s inbyggda teckensnitt (Ubuntu Light och Hack, under Ubuntu Font Licence och MIT/Bitstream Vera-licensen; Noto Emoji och emoji-ikonteckensnittet, under OFL och MIT). Skisstext använder Liberation Sans (SIL OFL 1.1). Crafting Apps valfria teckensnittspaket, craft-fonts (BIZ UDPGothic, Shippori Mincho, BIZ UDMincho, Noto Sans CJK SC, Noto Sans Arabic; SIL OFL 1.1), ingår inte i SolidCraft-byggen.", "help.fonts": "Teckensnitt", + "help.frame.member": "Placerar en profil, till exempel ett rör eller en aluminiumprofil, längs linjerna i en ramskiss, en ramdel per linje. Använd det för att bygga ramar, ställ och svetsade konstruktioner.", + "help.inspect.accessibility": "Färgar de ytor som ett verktyg kan nå från en riktning, och de som det inte kan nå. Använd det för att kontrollera en del för bearbetning eller formsprutning från den sidan.", + "help.inspect.center_of_mass": "Visar masscentrum för kroppar, var för sig och alla tillsammans, vid jämn densitet. Använd det för att kontrollera balans och var en del tippar.", + "help.inspect.colored_components": "Ger varje komponent en egen tillfällig färg, utan att ändra utseenden. Använd det för att snabbt skilja delarna i en sammanställning åt.", + "help.inspect.compare": "Jämför två kroppar och visar var den ena har material som den andra saknar, med volym och avvikelse. Använd det för att kontrollera en ändrad eller importerad del mot originalet.", + "help.inspect.continuity": "Kontrollerar hur jämnt ytor möts längs sina kanter: spalter, knäckar eller krökningshopp, och färgar skarvarna. Använd det för att hitta var ytor inte fogas rent.", + "help.inspect.curvature_comb": "Ritar kammar längs kurvor, kanter eller ytor vars tänder visar hur kraftigt de kröker sig. Använd det för att hitta plana ställen, knäckar och ojämn krökning.", + "help.inspect.curvature_map": "Färgar ytor efter hur kraftigt de kröker sig, rött där radien är liten. Använd det för att hitta snäva ställen och bedöma hur jämn en yta är.", + "help.inspect.draft": "Färgar ytor efter deras släppvinkel mot en dragriktning och markerar de som har för lite. Använd det för att kontrollera en del före formsprutning eller gjutning.", + "help.inspect.environment_map": "Visar modellen med en reflekterande, spegelliknande yta. Använd det för att bedöma ytornas förlopp utifrån hur reflektionerna löper över dem.", + "help.inspect.interference": "Hittar kroppar som överlappar varandra och visar varje överlapp med dess volym. Använd det för att kontrollera om delar i en sammanställning kolliderar.", + "help.inspect.isocurve": "Ritar ytors isoparametriska kurvor, det rutnät som ytan är byggd på. Använd det för att se hur en yta flyter och var den packas ihop.", + "help.inspect.measure": "Mäter längder, avstånd, vinklar, areor och radier mellan det du väljer, eller volym och massa hos kroppar. Använd det för att kontrollera storlekar och frigångar.", + "help.inspect.minimum_radius": "Hittar den minsta krökningsradien hos kurvor, kanter eller ytor och var den finns. Använd det för att kontrollera att ett verktyg eller ett munstycke kan följa formen.", + "help.inspect.section": "Skär upp vyn längs ett plan och skrafferar snittytorna, utan att ändra modellen. Använd det för att titta in i delar och sammanställningar.", + "help.inspect.zebra": "Visar svarta och vita ränder som speglas i modellens ytor. Använd det för att bedöma jämnhet: ränder som bryts eller knäcks vid en kant visar en dålig övergång.", + "help.joint.as_built": "Förbinder två delar där de redan är, utan att flytta någon av dem, och anger hur de fortfarande får röra sig. Använd det för delar som du redan har placerat i sitt slutliga läge.", + "help.joint.cam": "Länkar en roterande kam till en följare, så att följaren glider eller svänger när kamens kontur passerar under den. Använd det för kammar, excentrar och ventilmekanismer.", + "help.joint.create": "Förbinder två delar vid de ledursprung du väljer och flyttar den andra delen på plats; ledtypen anger hur de fortfarande får röra sig. Använd det för att bygga en sammanställning vars delar rör sig som den verkliga mekanismen.", + "help.joint.drive": "Anger en leds vinkel eller avstånd och flyttar delarna med den, inom dess gränser; länkade leder och slutna slingor följer med. Använd det för att kontrollera att en mekanism rör sig som avsett.", + "help.joint.gear": "Länkar två roterande leder med ett utväxlingsförhållande, så att den andra vrids när du driver den ena. Använd det för raka kugghjul och kuggkransar, med förhållandet från delningsradier eller kuggantal.", + "help.joint.limits": "Anger hur långt en led får vridas eller glida, som ett minimum och ett maximum, eller tar bort dessa gränser. Använd det för att hålla ett gångjärn, en glidande led eller en spak inom sitt verkliga rörelseomfång.", + "help.joint.motion_link": "Kopplar ihop två leder med ett förhållande och en förskjutning, så att den andra rör sig när du driver den ena. Använd det för ett fast samband mellan leder som Kugghjul, Kuggstång och drev eller Skruv inte täcker.", + "help.joint.origin": "Placerar ett namngivet ledursprung på en del: i centrum av en yta eller en rund ände, mitt på en kant, i ett hörn eller i en punkt. Använd det för att förbereda var leder fäster; ett som väljs på en yta följer den när delen ändrar storlek.", + "help.joint.rack_pinion": "Länkar ett roterande drev till en glidande kuggstång, så att kuggstången rör sig lika långt som den del av delningscirkeln som drevet rullar över. Använd det för kuggstångsdrifter, styrning och linjärstyrningar.", + "help.joint.reach": "Flyttar en punkt på en del mot ett mål genom att ändra lederna i dess kedja, inom deras gränser. Använd det för att ställa in en mekanism utifrån var dess ände ska vara, till exempel spetsen på en robotarm.", + "help.joint.rigid_group": "Låser de markerade delarna ihop som de är, så att de rör sig som en enhet. Använd det för delar som hör ihop och inte har någon rörelse mellan sig.", + "help.joint.screw": "Länkar vridning och glidning med en stigning, så att varje varv förflyttar delen det avståndet. Använd det för ledskruvar, bultar och gängade spindlar.", + "help.library.apply": "Tillämpar ett material eller utseende från en lokal biblioteksfil på kroppar, ytor eller komponenter. Använd det för att återanvända material som du sparat från en annan konstruktion.", + "help.library.export": "Sparar konstruktionens material och utseenden i en biblioteksfil. Använd det för att dela dem eller återanvända dem i andra konstruktioner.", + "help.library.inspect": "Kontrollerar en biblioteksfil och listar dess material och utseenden, utan att ändra konstruktionen. Använd det för att se vad ett bibliotek innehåller innan du tillämpar det.", "help.licences": "Licenser", "help.material.assign": "Anger vad de markerade kropparna är gjorda av, till exempel stål, aluminium eller ABS. Använd det så att massa och vikt stämmer med den verkliga delen.", + "help.material.properties.validate": "Kontrollerar att ett materialkort har konsekventa tal och inga saknade värden. Använd det innan du förlitar dig på data för massa eller styvhet.", + "help.mesh.convert": "Bygger om en meshkropp, till exempel en STL, till en redigerbar solid med plana och cylindriska ytor. Använd det för att ändra importerade mekaniska delar som om de vore en modellerad kropp.", "help.mit": "MIT-licens", + "help.motion.study": "Anger ledvärden vid steg längs en tidslinje och spelar upp dem som en animation. Använd det för att se en mekanism röra sig genom en sekvens och kontrollera frigångar längs vägen.", + "help.motion.trace": "Spårar den bana som en punkt på en del följer medan en rörelsestudie spelas upp eller en led drivs, och behåller den som en 3D-skisskurva när du bekräftar. Använd det för att se var en mekanism når, eller för att bygga vidare på dess bana.", "help.notice": "NOTICE", + "help.occurrence.ground": "Fixerar en del på plats, eller frigör den igen, så att leder flyttar de andra delarna runt den. Använd det för ramen eller basen som resten av sammanställningen byggs på.", "help.occurrence.move": "Flyttar en del i sammanställningen endast längs de frihetsgrader som dess leder tillåter. Markera en yta på delen och dra sedan en tillgänglig pil eller båge.", "help.parameters.change": "Öppnar tabellen med namngivna värden och uttryck som styr konstruktionens mått. Använd den för att ändra en storlek på ett ställe och låta varje funktion som använder den följa med.", + "help.parameters.export": "Sparar konstruktionens parametrar som CSV eller JSON. Använd det för att redigera dem i ett kalkylblad eller flytta dem till en annan konstruktion.", + "help.parameters.graph": "Visar vilka parametrar och funktioner som använder vilka andra, och eventuella cirkulära beroenden eller fel bland dem. Använd det för att se vad en ändring av en parameter påverkar.", + "help.parameters.import": "Läser in parametrar från CSV eller JSON i konstruktionen, antingen alla eller inga. Använd det för att hämta in värden från ett kalkylblad eller en annan konstruktion.", + "help.parameters.move": "Flyttar en användarparameter före eller efter en annan i listan. Använd det för att hålla relaterade parametrar samlade.", + "help.parts.fastener": "Lägger till en standardiserad skruv, bult, mutter, bricka, stift eller ett lager i den storlek du väljer. Placeras den på ett hål sätts den fast i hålet med en led, så att du inte behöver modellera fästelement själv.", + "help.parts.insert": "Infogar en standarddel i den familj, storlek och längd du väljer. Placeras den på kanten eller väggen av ett hål sätts den fast mot hålet med en stel led.", + "help.parts.library": "Öppnar standarddelarna: skruvar, bultar, muttrar, brickor, stoppskruvar, passtift och lager i standardstorlekar. Använd det för att lägga till en standarddel i stället för att modellera den själv.", + "help.parts.resize": "Ändrar storleken eller längden på en infogad standarddel och bygger om den; dess leder håller den på plats. Använd det när en skruv visar sig vara för lång eller ett hål behöver en annan storlek.", + "help.plastic.assign_rule": "Tilldelar en plastregel till kroppar, så att deras plastfunktioner tar dess släppvinkel och frigång som standardvärden. Använd det för att ställa in en formsprutad del en gång för alla dess funktioner.", + "help.plastic.boss": "Lägger till ett skruvnav på en yta: en pelare med valfritt hål, släppvinkel, rotavrundning och stödjande revben. Använd det för skruvpunkter i formsprutade eller utskrivna höljen.", + "help.plastic.lip": "Lägger till en läpp eller ett matchande spår längs kanten på en urholkad kropp. Använd det så att två höljeshalvor centreras mot varandra och sluter tätt.", + "help.plastic.manage_rules": "Skapar och redigerar plastregler: material, väggtjocklek, radier, frigång och släppvinkel. Använd dem för att hålla funktionerna i en formsprutad del enhetliga.", + "help.plastic.rest": "Lägger till ett stöd på en yta: en rund eller rektangulär platta, massiv eller ihålig, med släppvinkel. Använd det som stöd eller distans i ett formsprutat hölje.", + "help.plastic.snap_fit": "Lägger till en snäppkrok på en yta, med angiven längd, tjocklek och hake. Använd det för att snäppa ihop delar utan skruvar.", "help.pointcloud.insert": "Infoga en skanning i formatet PLY, XYZ eller PTS som ett punktmoln vid sidan av modellen. Använd den som referens när du bygger en modell från uppmätta data.", + "help.sheet.convert": "Gör en platta eller urholkad kropp med jämn tjocklek till en plåtkropp, med böjar vid de kanter du väljer. Använd det för att göra en befintlig del utvikbar.", + "help.sheet.export_bend_table": "Sparar böjtabellen för en plåtkropp som CSV eller JSON: vinklar, radier och böjlägen i det plana mönstret. Använd det för kantpressen eller verkstaden.", + "help.sheet.export_dxf": "Sparar det plana mönstret som en DXF, med konturer, böjlinjer och valfria böjanteckningar. Använd det för laser-, plasma- eller vattenskärning.", + "help.sheet.flange": "Lägger till en fläns: en grundplåt utifrån en skiss, en vägg längs en öppen skisskontur eller en böjd vägg på plåtkanter. Använd det för att bygga upp plåtdelar.", + "help.sheet.flat_pattern": "Skapar det plana mönstret för en plåtkropp, med dess storlek, utskärningar och böjlinjer. Använd det för att kontrollera plåtämnet innan du skär ut det.", + "help.sheet.fold": "Böjer en plåt längs en skisslinje med en vinkel och en radie. Använd det för att vika en plan plåt efter att du skurit ut funktioner i den.", + "help.sheet.hem": "Viker en plåtkant tillbaka mot sig själv. Använd det för säkra, styva kanter utan vassa grader.", + "help.sheet.manage_rules": "Skapar och redigerar plåtregler: tjocklek, böjradie, K-faktor, avlastningar och spalter. Använd dem för att anpassa efter materialet och verktygen i din verkstad.", + "help.sheet.path_flange": "Lägger till en kedja av flänsar längs en plåtkant, efter en öppen kedja av skisslinjer. Använd det för profiler som en U- eller Z-form i ett enda steg.", + "help.sheet.perforate": "Lägger till ett hålfält över hela den plana plåten, i ett förskjutet eller kvadratiskt mönster. Använd det för ventilation, galler och lättare paneler.", + "help.sheet.refold": "Viker ihop en utvikt plåtkropp igen. Använd det efter att du lagt till funktioner i den plana plåten.", + "help.sheet.unfold": "Viker ut alla böjar på en plåtkropp. Använd det för att lägga till hål eller skärningar som måste korsa en böj, och vik sedan ihop den igen.", + "help.sketch.arc.center_point": "Ritar en cirkelbåge runt en centrumpunkt, från en startpunkt till en slutpunkt. Använd det när bågens centrum är känt, till exempel en radie runt ett hål eller en vridpunkt.", + "help.sketch.arc.tangent": "Ritar en båge som börjar i änden av en linje eller båge och fortsätter tangentiellt mot den. Använd det för att fortsätta en kedja med en mjuk böj i stället för ett hörn.", + "help.sketch.arc.three_point": "Ritar en båge från en startpunkt till en slutpunkt genom en tredje punkt. Använd det när bågen ska gå genom givna punkter i stället för runt ett känt centrum.", + "help.sketch.arc3d": "Ritar en exakt cirkelbåge i rymden genom tre punkter, med 3D-skiss på. Använd det för böjar i banor och styrkurvor som lämnar skissplanet.", + "help.sketch.auto_constrain": "Lägger till de villkor som skissen nästan redan har, till exempel horisontell, parallell eller sammanfallande, och sätter sedan mått på den tills den är fullt definierad. Använd det för att låsa en grovt ritad skiss i ett enda steg.", + "help.sketch.auto_constrain_from_datum": "Fungerar som Autovillkor, men mäter lägena från en datumpunkt som du väljer i stället för origo. Använd det när delen läggs ut från en enda referenspunkt, till exempel ett hörn eller ett hål.", + "help.sketch.blend_curve": "Förbinder ändarna på två kurvor med en mjuk spline som tangerar båda, eller som även är krökningskontinuerlig om du väljer det. Använd det för att överbrygga ett glapp mellan kurvor utan knäck.", + "help.sketch.break": "Delar en kurva i bitar där andra kurvor korsar den, eller vid punkter som du placerar på den. Använd det när en del av en kurva behöver ett eget villkor eller ska tas bort för sig.", + "help.sketch.cancel": "Lämnar skissen och förkastar allt som ändrats sedan den skapades eller öppnades för redigering. Ångra tar tillbaka ändringarna.", + "help.sketch.centerline": "Växlar linjer mellan vanliga linjer och centrumlinjer, axlar som profiler utelämnar. Använd en centrumlinje som axel för en rotation, en spegling eller ett symmetrivillkor.", + "help.sketch.chamfer.distance_angle": "Fasar hörnet mellan två linjer, bestämt av ett avstånd längs den första linjen och en vinkel. Använd det när en ritning anger fasningen som en längd och en vinkel, till exempel 2 mm × 30°.", + "help.sketch.chamfer.equal_distance": "Fasar hörnet mellan två linjer lika långt bakåt längs båda. Använd det för den vanliga 45°-fasningen på en kontur.", + "help.sketch.chamfer.two_distance": "Fasar hörnet mellan två linjer, bestämt av ett separat avstånd längs varje linje. Använd det för fasningar som inte är 45°, angivna som två längder.", + "help.sketch.circle.center": "Ritar en cirkel utifrån dess centrum och diameter. Använd det för hål, nav och andra runda former vars centrum är känt.", + "help.sketch.circle.three_point": "Ritar en cirkel genom tre punkter. Använd det när cirkeln ska gå genom givna punkter, till exempel på befintlig geometri.", + "help.sketch.circle.three_tangent": "Ritar en cirkel som tangerar tre linjer, cirklar eller bågar. Använd det för att passa in en cirkel i utrymmet mellan tre kurvor, till exempel en triangels inskrivna cirkel.", + "help.sketch.circle.two_point": "Ritar en cirkel utifrån diameterns två ändpunkter. Använd det när cirkelns kantpunkter är kända i stället för dess centrum.", + "help.sketch.circle.two_tangent": "Ritar en cirkel med en given storlek som tangerar två linjer, cirklar eller bågar. Använd det för en avrundning med fast radie mellan två kurvor.", + "help.sketch.conic": "Ritar en konisk kurva mellan två ändpunkter, formad av en toppunkt och ett rho-värde. Använd det för mjuka övergångar vars fyllighet du vill finjustera, från platt till nästan ett skarpt hörn.", + "help.sketch.constrainer": "Tillämpar det villkor som en eller två valda kurvor nästan redan har, till exempel horisontell, parallell, tangent eller lika. Använd det för att städa upp en frihandsritad skiss ett samband i taget.", + "help.sketch.constraint.coincident": "Gör att en punkt ligger på en annan punkt, en kurva eller en skissaxel. Använd det för att täppa till glapp i en profil eller för att lägga en punkt på en linje.", + "help.sketch.constraint.collinear": "Gör att två linjer ligger på samma räta linje. Använd det för att rikta in avbrutna kanter, till exempel båda sidor av ett urtag.", + "help.sketch.constraint.concentric": "Ger två cirklar eller bågar samma centrum. Använd det för ringar, försänkningar och axlar med skuldror.", + "help.sketch.constraint.curvature": "Låter två kurvor som möts i en ändpunkt fortsätta med samma krökning (G2). Använd det där en tangentiell övergång inte är tillräckligt jämn, till exempel på synliga ytor.", + "help.sketch.constraint.equal": "Gör linjer lika långa, eller cirklar och bågar med samma radie. Använd det så att ett mått styr flera motsvarande former.", + "help.sketch.constraint.fix": "Fäster punkter eller kurvor där de är, eller lossar dem igen. Använd det för geometri som inte får flytta sig när du drar i eller löser skissen.", + "help.sketch.constraint.horizontal_vertical": "Gör en linje, eller linjen mellan två punkter, horisontell eller vertikal, beroende på vilket som ligger närmast. Använd det för att räta upp en kontur mot skissaxlarna.", + "help.sketch.constraint.midpoint": "Placerar en punkt på mitten av en linje, eller låter två linjers mittpunkter sammanfalla. Använd det för att centrera geometri på en kant.", + "help.sketch.constraint.parallel": "Gör två linjer parallella. Använd det för sidor som ska förbli parallella när andra mått ändras.", + "help.sketch.constraint.perpendicular": "Låter två linjer mötas i rät vinkel. Använd det för räta hörn mellan linjer som inte ligger längs skissaxlarna.", + "help.sketch.constraint.polygon": "Gör en sluten kedja av linjer till en regelbunden polygon: lika långa sidor, med varje hörn på samma cirkel. Använd det för att göra en frihandsritad polygon regelbunden och hålla den så.", + "help.sketch.constraint.symmetry": "Låter två punkter eller kurvor spegla varandra över en linje eller kring en punkt. Använd det för att hålla en profil symmetrisk när du ändrar ena sidan.", + "help.sketch.constraint.tangent": "Låter två kurvor tangera varandra, till exempel en linje och en båge. Använd det för mjuka övergångar utan knäck.", + "help.sketch.construction": "Växlar kurvor mellan normal geometri och konstruktionsgeometri, som profiler utelämnar. Använd konstruktionskurvor som hjälpmedel för layout, referenser och mått.", "help.sketch.construction_line": "Skapa en oändlig konstruktionslinje genom två punkter. Använd den för att lägga upp en skiss utan att dela profilerna.", "help.sketch.construction_line.angle": "Skapa en oändlig konstruktionslinje i en vinkel mot skissens X-axel. Välj punkten och skriv vinkeln, eller välj dess riktning.", "help.sketch.construction_line.bisector": "Välj två skärande raka skisslinjer och välj sedan någon av vinkelbisektriserna. Resultatet följer sina källlinjer.", @@ -1412,11 +1564,71 @@ "help.sketch.construction_line.tangent_external": "Välj en cirkel eller cirkelbåge, sedan en yttre punkt och en tangentlösning. Förhandsvisningarna visar bara beröringspunkter på den ritade bågen.", "help.sketch.construction_line.tangent_two": "Välj två cirklar eller cirkelbågar och välj sedan en gemensam tangent i förhandsvisningen. Beröringspunkterna måste ligga på de ritade kurvorna.", "help.sketch.construction_line.vertical": "Skapa en oändlig vertikal konstruktionslinje genom en punkt. Det vertikala villkoret gäller även efter senare ändringar.", + "help.sketch.copy": "Kopierar de markerade skisskurvorna och skisspunkterna, tillsammans med villkoren och måtten mellan dem. Klistra in dem i den här skissen eller i en annan.", + "help.sketch.create": "Startar en ny 2D-skiss på ett plan eller på en plan yta på modellen. Varje extrudering, rotation och svepning börjar med en skiss.", + "help.sketch.cut": "Kopierar den markerade skissgeometrin och tar bort den från skissen, i ett enda ångrasteg. Klistra in den någon annanstans i den här skissen eller i en annan.", + "help.sketch.dimension": "Lägger till ett styrande mått: en längd, ett avstånd, en vinkel, en radie eller en diameter, beroende på vad du väljer. Skriv ett värde eller ett parameternamn så följer skissen det.", + "help.sketch.edit": "Öppnar en befintlig skiss för redigering. Funktionerna som bygger på den uppdateras när du avslutar skissen.", + "help.sketch.ellipse": "Ritar en ellips utifrån dess centrum, änden av dess huvudaxel och en punkt på dess korta sida. Använd det för ovala öppningar, kammar och andra avlånga runda former.", "help.sketch.equation_curve": "Ritar en kurva utifrån formler: x(t) och y(t), eller y = f(x), över ett intervall. Formlerna kan använda parametrar och kurvan följer dem: använd den för en sinusvåg, en spiral eller evolventen på en kugge.", + "help.sketch.extend": "Förlänger en linje eller båge till närmaste kurva i dess väg, eller förlänger två kurvor tills de möts. Använd det för att täppa till glapp i en profil efter trimning eller redigering.", + "help.sketch.fillet": "Rundar av hörnet där två linjer eller bågar möts med en båge med en given radie. Flera hörn som rundas av tillsammans delar samma radie.", + "help.sketch.finish": "Lämnar skissen och återgår till modellen. Dess slutna profiler är då redo för Extrudera, Rotation och andra funktioner.", + "help.sketch.finish_auto_constrain": "Kör Autovillkor på skissen och avslutar den sedan. Använd det för att lämna en skiss fullt definierad i ett enda steg.", + "help.sketch.fit_curves_to_section": "Skär en mesh- eller solidkropp med skissplanet och anpassar linjer och bågar till snittet. Använd det för att bygga om en skannad eller importerad del som en redigerbar skiss.", + "help.sketch.include_3d": "Tar in modellkanter, hörn och kurvor från andra skisser i den här skissen som länkade referenser och plattar ut sådana som ligger utanför dess plan på det. Använd det för att bygga vidare på geometri som inte ligger i skissplanet.", + "help.sketch.infinite": "Växlar linjer mellan vanliga linjer och oändliga konstruktionslinjer som löper över hela vyn. Använd dem som layoutreferenser som aldrig tar slut för tidigt.", + "help.sketch.insert_dxf": "Läser in kurvorna från en DXF-ritning i en skiss, skalade, vridna och placerade där du anger. Använd det för att bygga vidare på en 2D-ritning från ett annat program, till exempel en kontur för laserskärning.", + "help.sketch.insert_svg": "Läser in banorna från en SVG-ritning i en skiss, skalade, vridna och placerade där du anger. Använd det för att göra en logotyp eller en vektorkontur till kurvor som du kan extrudera.", + "help.sketch.inspect": "Öppnar en rapport om den öppna skissen: hur många frihetsgrader som återstår och om den är fullt definierad. Använd det för att ta reda på vad som fortfarande rör sig innan du avslutar skissen.", + "help.sketch.intersect": "Lägger till de kurvor eller punkter där kroppar, ytor, kanter eller andra skisser korsar skissplanet. De förblir länkade och följer modellen när den ändras.", + "help.sketch.intersection_curve": "Skapar 3D-kurvor där två kroppar eller ytor möts, länkade till dem. Använd dem som banor eller styrkurvor som följer modellen.", + "help.sketch.isoparametric_curve": "Skapar en 3D-kurva längs en ytas U- eller V-riktning genom en punkt som du väljer, länkad till ytan. Använd den som en bana eller delningslinje som följer ytan.", + "help.sketch.line": "Ritar sammanhängande linjer punkt för punkt, som en öppen kedja eller en sluten kontur. Det är det grundläggande verktyget för de flesta profiler.", + "help.sketch.line.midpoint": "Ritar en linje från dess mittpunkt ut till ena änden, med den andra änden speglad. Använd det för linjer centrerade på en punkt, till exempel en symmetrisk kant.", + "help.sketch.line3d": "Ritar sammanhängande raka linjer mellan punkter i rymden, med 3D-skiss på. Använd det för banor och ramar som lämnar skissplanet.", + "help.sketch.mirror": "Speglar skisskurvor och skisspunkter över en linje eller axel och lägger till symmetrivillkor. Använd det för att rita ena halvan av en symmetrisk profil och komplettera den.", + "help.sketch.move": "Flyttar eller roterar skisskurvor och skisspunkter, eller en kopia av dem. Använd det för att flytta om geometri och samtidigt behålla dess form och villkor.", + "help.sketch.offset": "Skapar en kopia av en kedja av kurvor på ett bestämt avstånd, på ena sidan eller båda. Använd det för väggar, konturer och frigångar; avståndet förblir ett redigerbart mått.", + "help.sketch.paste": "Klistrar in kopierad eller utklippt skissgeometri i den aktiva skissen. Placera den vid en punkt eller flytta den ett visst avstånd.", + "help.sketch.pattern.circular": "Kopierar skissgeometri runt en centrumpunkt, över ett helt varv eller en bestämd vinkel. Använd det för hålcirklar, ekrar och tänder.", + "help.sketch.pattern.rectangular": "Kopierar skissgeometri i rader och kolumner med bestämda avstånd. Använd det för rutnät av hål, spår eller ventilationsöppningar.", + "help.sketch.point": "Placerar en skisspunkt. Använd den som referens för mått, villkor, hålcentrum eller senare funktioner.", + "help.sketch.point3d": "Placerar en punkt i rymden, med 3D-skiss på. Använd den som referens för 3D-kurvor och banor.", + "help.sketch.polygon.circumscribed": "Ritar en regelbunden polygon runt en cirkel, med storleken bestämd från dess centrum till mitten av en sida. Använd det när nyckelvidden är känd, som för en sexkantsmutter.", + "help.sketch.polygon.edge": "Ritar en regelbunden polygon utifrån en sidas två ändpunkter. Använd det när längden och läget på en sida är kända.", + "help.sketch.polygon.inscribed": "Ritar en regelbunden polygon inuti en cirkel, med storleken bestämd från dess centrum till ett hörn. Använd det när måttet över hörnen är känt.", + "help.sketch.project": "Tar in modellkanter, ytor, hörn och kurvor från andra skisser i den här skissen, projicerade på dess plan. De förblir länkade och följer modellen när den ändras.", + "help.sketch.project_to_surface": "Projicerar kurvor från andra skisser längs den här skissens normal på en yta, som länkade 3D-kurvor. Använd det för att lägga en logotyp, delningslinje eller bana på en böjd yta.", + "help.sketch.rectangle.center": "Ritar en rektangel utifrån dess centrum och ett hörn. Använd det för konturer centrerade på en punkt, till exempel origo.", + "help.sketch.rectangle.rounded": "Ritar en rektangel med alla fyra hörn avrundade med samma radie. Använd det för plattor, lock och öppningar med rundade hörn.", + "help.sketch.rectangle.three_point": "Ritar en rektangel utifrån en kant och en punkt på motsatt sida. Använd det för rektanglar som ligger i vinkel mot skissaxlarna.", + "help.sketch.rectangle.two_point": "Ritar en rektangel utifrån två motstående hörn. Använd det för konturer av plattor, block och fickor.", + "help.sketch.scale": "Skalar skisskurvor och skisspunkter med en faktor kring en baspunkt. Använd det för att ändra storlek på en del av en skiss utan att rita om den.", + "help.sketch.slot.arc_center": "Ritar ett böjt spår med en given bredd längs en båge som bestäms av dess centrum, start och slut. Använd det för justerspår runt en vridpunkt.", + "help.sketch.slot.arc_three_point": "Ritar ett böjt spår med en given bredd längs en båge genom tre punkter. Använd det när spårets bana ska gå genom givna punkter.", + "help.sketch.slot.center_point": "Ritar ett rakt spår med en given bredd från dess centrum till centrum av ena ändbågen. Använd det för spår centrerade på en punkt.", + "help.sketch.slot.center_to_center": "Ritar ett rakt spår med en given bredd mellan centrum för dess två ändbågar. Använd det för spår som mäts centrum till centrum, till exempel justerspår för skruvar.", + "help.sketch.slot.overall": "Ritar ett rakt spår med en given bredd från ena yttre änden till den andra. Använd det när spårets totala längd är det som räknas.", + "help.sketch.spline.control_point": "Ritar en kubisk spline som formas av kontrollpunkter som den inte går genom. Använd det för mjuka, flytande kurvor som du formar genom att flytta punkterna.", + "help.sketch.spline.control_point_5": "Ritar en spline av grad 5 som formas av kontrollpunkter. Använd det där kurvor ska mötas med jämnare krökning, till exempel på synliga designytor.", + "help.sketch.spline.fit_point": "Ritar en mjuk spline som går genom varje punkt du placerar, öppen eller sluten. Använd det för att följa en form genom kända punkter.", + "help.sketch.spline3d": "Ritar en mjuk kurva i rymden genom punkter, med 3D-skiss på. Använd det för svepbanor och kabeldragningar som lämnar skissplanet.", + "help.sketch.spun_profile": "Skapar i skissplanet den kontur som en kropp sveper över när den vrids kring en axel. Använd det för att få silhuetten av en svarvad del till en rotation eller en ritning.", + "help.sketch.text": "Skriver text i skissen med valt teckensnitt, vald höjd och vinkel; orden kan följa parametrar. Extrudera eller prägla den för etiketter och märkning.", + "help.sketch.transform": "Skalar, vrider och flyttar skissgeometri kring en centrumpunkt i ett enda steg. Använd det för att flytta om och ändra storlek på en markering samtidigt.", + "help.sketch.trim": "Tar bort den bit av en kurva som ligger mellan de närmaste skärningarna runt den punkt du klickar på. Använd det för att städa upp överlappande linjer till en sluten profil.", "help.solid.align": "Flyttar de markerade kropparna så att en punkt, yta eller ett plan på dem möter en punkt, yta eller ett plan någon annanstans. Använd det för att placera en del mot en annan utan att mäta förskjutningen.", + "help.solid.boundary_fill": "Skapar solider av de områden som omsluts av överlappande verktygskroppar, och fyller bara de områden du väljer. Använd det för att bygga en form av det utrymme som flera kroppar tillsammans avgränsar.", + "help.solid.bounding_solid": "Skapar ett block som omsluter de markerade kropparna, med valfri marginal. Använd det för att se en dels totala storlek, eller som det ämne den bearbetas ur.", + "help.solid.box": "Skapar ett block utifrån längd, bredd och höjd, från en hörnpunkt eller centrerat på en punkt. Använd det för ett snabbt block, en platshållare eller en kropp att skära funktioner i.", "help.solid.chamfer": "Fasar de markerade kanterna med en plan fas på det avstånd du anger. Använd det för att bryta skarpa kanter eller underlätta införandet av ett stift eller en skruv.", + "help.solid.coil": "Skapar en spiralformad kropp, till exempel en fjäder, utifrån diameter, antal varv, höjd eller stigning och ett runt eller kvadratiskt tvärsnitt. Använd det för fjädrar, spolar och spiralformade åsar.", "help.solid.combine": "Sammanfogar kroppar, skär bort dem från en målkropp eller behåller bara den volym de delar, och kan behålla verktygskropparna. Använd det för att slå ihop delar, skära en form med en annan eller hitta var två kroppar överlappar.", + "help.solid.cone": "Skapar en kon utifrån en bascirkel och en höjd, eller en stympad kon när toppen också har en radie. Använd det för avsmalningar, trattar, kägelsänkningar och spetsar.", + "help.solid.cylinder": "Skapar en cylinder utifrån en radie eller diameter och en höjd, stående på en punkt längs en axel. Använd det för axlar, stift, nav och runda ämnen.", "help.solid.draft": "Lutar de markerade ytorna med den vinkel du anger, mätt från ett neutralt plan. Använd det på gjutna eller formsprutade delar så att de släpper ur formen.", + "help.solid.emboss": "Höjer skissprofiler, till exempel text eller en logotyp, upp ur en yta, eller sänker ned dem i den. Använd det för text, etiketter och markeringar på plana eller runda ytor.", "help.solid.extrude": "Trycker en skissprofil ut i 3D över ett avstånd och lägger till eller tar bort material. Använd det för att göra en plan kontur till ett block, ett nav eller en ficka.", "help.solid.fillet": "Avrundar de markerade kanterna med en jämn cirkulär övergång med den radie du anger. Använd det för att mjuka upp skarpa kanter för hållfasthet, bekvämlighet eller utseende.", "help.solid.hole": "Borrar ett runt hål med den diameter du anger vid en vald punkt på en yta. Använd det för frigångshål till skruvar, stift och andra enkla runda öppningar.", @@ -1425,20 +1637,60 @@ "help.solid.move": "Flyttar eller roterar de markerade kropparna, eller en kopia av dem, med det avstånd och den vinkel du anger. Använd den för att flytta om en kropp eller skapa en till på ett annat ställe.", "help.solid.offset_face": "Skjuter de markerade ytorna inåt eller utåt med det avstånd du anger, och ytorna runt omkring följer med. Använd den för att göra en vägg tjockare, en ficka djupare eller ett nav högre utan att redigera skissen.", "help.solid.pattern.circular": "Upprepar de markerade objekten runt en axel, med det antal och den totala vinkel du anger. Använd den för hålcirklar, ekrar, kylflänsar och tänder.", + "help.solid.pattern.path": "Kopierar funktioner eller kroppar längs en skissbana, med antal och avstånd. Använd det för hål längs en böjd kant eller delar längs en skena.", "help.solid.pattern.rectangular": "Upprepar de markerade objekten i en eller två riktningar med det antal och det avstånd du anger. Använd den för rader och rutnät av hål, revben, stift eller fötter.", + "help.solid.pipe": "Skapar ett runt rör längs en bana av skisskurvor, massivt eller ihåligt med en väggtjocklek. Använd det för rör, trådar, kablar och skenor.", "help.solid.press_pull": "Lägger till eller tar bort material när du drar i en yta, eller rundar av kanter när du drar i dem. Använd den för snabba ändringar direkt på modellen.", "help.solid.remove": "Tar bort de markerade kropparna från konstruktionen från och med den här punkten i tidslinjen; tidigare steg behåller dem. Använd den för att bli av med en hjälpkropp, till exempel ett skärverktyg som du inte längre behöver.", "help.solid.replace_face": "Flyttar de markerade plana ytorna till ett plan eller till en parallell yta, och kroppen växer eller krymper så att den når fram. Använd den för att få en yta i nivå med en annan del eller ett konstruktionsplan.", "help.solid.revolve": "Roterar en skissprofil runt en axel och skapar en rund kropp. Använd den för axlar, vred, skålar och alla delar som är runda kring en mittlinje.", + "help.solid.rib": "Lägger till en tunn vägg längs en öppen skisskurva, som fylls ut till kroppens närmaste ytor. Använd det för att stärka väggar och hörn, till exempel i formsprutade eller utskrivna delar.", "help.solid.scale": "Gör de markerade kropparna större eller mindre med en faktor, lika i alla riktningar eller en per axel. Använd den för att ändra storlek på en importerad form eller för att ta hänsyn till krympning.", "help.solid.shell": "Urholkar en kropp så att väggar med den tjocklek du anger blir kvar, och kan öppna de ytor du väljer. Använd den för lådor, lock och andra tunnväggiga delar.", + "help.solid.sphere": "Skapar en sfär utifrån en radie eller diameter kring en centrumpunkt. Använd det för kulor, knoppar och rundade ändar.", "help.solid.split_body": "Delar en kropp i separata kroppar med ett plan, ytan på en annan kropp eller en skisslinje. Använd den för att skapa delar som skrivs ut, bearbetas eller färgas var för sig.", "help.solid.split_face": "Delar de markerade ytorna längs ett plan, ytan på en annan kropp eller skisskurvor, utan att skära i kroppen. Använd den för att ge en del av en yta egen släppvinkel, färg eller förskjutning.", "help.solid.sweep": "Drar en skissprofil längs en bana och skapar en kropp. Använd den för rör, skenor, handtag och alla delar med konstant tvärsnitt som följer en kurva.", + "help.solid.thread": "Lägger till en ISO-metrisk gänga på en cylindrisk yta, antingen som en kosmetisk gänga eller skuren in i kroppen. Använd det för bultar, gängade hål och utskrivna skruvdelar.", + "help.solid.torus": "Skapar en ring utifrån en stor radie kring dess axel och en liten radie för dess runda tvärsnitt. Använd det för O-ringar, handtag och runda pärlor.", + "help.solid.web": "Lägger till en tunn vägg längs varje öppen skisskurva, var och en utfylld till kroppens närmaste ytor. Använd det för ett rutnät av stödväggar inuti höljen och brickor.", + "help.surface.blend": "Skapar en yta som överbryggar kanterna på två kroppar, tangent- eller krökningskontinuerlig mot båda. Använd det för att foga samman två ytor mjukt över ett glapp.", + "help.surface.boundary": "Skapar en yta som fyller en slinga av två till fyra skisskurvor, eller ett nät av kurvor. Använd det för friformade paneler som bestäms av sina kanter.", + "help.surface.extend": "Förlänger en yta förbi dess kanter, med ett avstånd eller upp till ett plan eller en yta. Använd det så att ytor överlappar innan du trimmar eller syr ihop dem.", + "help.surface.extrude": "Trycker skisskurvor eller profiler ett visst avstånd till en yta, bara väggar, utan lock. Använd det för tunna väggar och för skär- eller delningsverktyg.", + "help.surface.fair": "Jämnar ut det inre av friformade ytor medan deras kanter ligger kvar. Använd det för att jämna ut bulor och vågiga reflektioner i en yta.", + "help.surface.loft": "Skapar en yta som går genom två eller flera tvärsnitt i ordning, utan lock. Använd det för skal och övergångar mellan olika former.", + "help.surface.match": "Bygger om kanten på en yta så att den möter en annan kropps kant, med matchande tangent eller krökning. Använd det för att sluta en skarv mjukt mellan två ytor.", + "help.surface.offset": "Skapar en kopia av ytor på ett avstånd längs deras normaler, som en ny ytkropp. Använd det för ytskikt, frigångar eller andra sidan av en vägg.", + "help.surface.patch": "Fyller ett skissområde eller en sluten slinga av kanter med en yta, plan eller mjukt böjd. Använd det för att täppa till hål i en ytkropp.", + "help.surface.reverse_normal": "Vänder ytor på en ytkropp, så att deras utsida vetter åt andra hållet. Använd det före förtjockning eller ihopsyning när ytorna pekar inåt.", + "help.surface.revolve": "Vrider skisskurvor eller profiler kring en axel till en yta, utan lock. Använd det för runda skal som lampskärmar, skålar och munstycken.", + "help.surface.ruled": "Skapar ett band av raka linjer ut från en kedja av kanter, längs ytans normal, tangent eller i en vinkel. Använd det för väggar med släppvinkel, utlopp och delningsytor.", + "help.surface.stitch": "Syr ihop ytkroppar längs sina gemensamma kanter, till en solid när de sluter sig. Använd det för att göra en uppsättning ytor till en enda kropp.", + "help.surface.sweep": "Drar skisskurvor eller profiler längs en bana till en yta, utan lock. Använd det för hängrännor, lister och andra tunna former som följer en bana.", + "help.surface.thicken": "Ger en ytkropp en tjocklek och gör den till en solid. Använd det för att göra utskrivbara eller bearbetningsbara delar av ytor.", + "help.surface.trim": "Skär en yta med ett plan, en annan yta eller skisskurvor och behåller den sida du väljer. Använd det för att skära ytor till form före ihopsyning.", + "help.surface.unstitch": "Separerar ytorna på en kropp till egna ytkroppar; en solid faller isär i ytor. Använd det för att arbeta om enskilda ytor på en kropp.", + "help.surface.untrim": "Tar bort hål från en yta, eller återställer dess yttre gräns till ytans naturliga gränser. Använd det för att få tillbaka hela ytan efter en trimning.", "help.third_party": "Licenser från tredje part", "help.timeline.compute_all": "Bygger om alla funktioner i tidslinjen från början. Använd den för att uppdatera modellen och se vilka funktioner som misslyckas.", "help.timeline.delete": "Tar bort de markerade funktionerna från tidslinjen; om du tar bort en skiss tas även funktionerna som bygger på den bort. Använd den för att ta bort ett steg som inte längre behövs.", + "help.timeline.dependents": "Listar de funktioner som skulle tas bort tillsammans med en funktion, och de som skulle misslyckas utan den. Använd det innan du tar bort eller ändrar en funktion.", "help.timeline.play": "Spela upp de återstående funktionerna en i taget, med en halv sekunds paus efter varje ombyggnad. Kör kommandot igen eller tryck på Escape för att stoppa; Gå till slutet hoppar direkt.", + "help.view.apply": "Vrider vyn till en standardriktning som framifrån, ovanifrån eller iso, eller till en sparad namngiven vy. Använd det för att titta på modellen från en fast vinkel.", + "help.view.delete": "Tar bort en sparad namngiven vy. Modellen ändras inte.", + "help.view.fit": "Zoomar och panorerar så att hela modellen, eller markeringen, fyller vyn. Använd det när modellen har hamnat utom synhåll.", + "help.view.grid": "Visar eller döljer rutnätet i vyfönstret. Använd det som hjälp för storlek och orientering under modellering.", + "help.view.ground_shadow": "Visar eller döljer modellens skugga på marken. Använd det för att lättare bedöma höjd och läge.", + "help.view.home": "Vrider tillbaka vyn till hemvyn och anpassar den efter modellen. Använd det för att snabbt komma tillbaka till en känd vy.", + "help.view.list": "Listar de namngivna vyer som sparats i konstruktionen. Använd det för att hitta en vy som du sparat tidigare.", + "help.view.navigate": "Vrider, panorerar eller zoomar vyn i steg, till exempel ett kvarts varv åt vänster. Använd det för att flytta kameran exakt i stället för att dra.", + "help.view.navigation_bar": "Visar eller döljer navigeringsfältet längst ned i vyfönstret. Dölj det för mer utrymme, visa det för att nå vyverktygen med musen.", + "help.view.rename": "Ger en sparad namngiven vy ett nytt namn. Själva vyn förblir densamma.", + "help.view.save": "Sparar den aktuella kameraplaceringen som en namngiven vy i konstruktionen. Använd det för att komma tillbaka till samma vinkel senare, till exempel för skärmbilder.", + "help.view.spin": "Orbiterar vyn kring modellen kontinuerligt till nästa klick eller tangenttryckning. Använd det för att visa en modell från alla sidor.", + "help.view.tool": "Låser ett kameraverktyg, orbitera, panorera eller zooma, så att dragning med vänster musknapp flyttar vyn tills du stänger av det. Använd det på en styrplatta eller utan mittenmusknapp.", + "help.view.visual_style": "Växlar hur modellen ritas: skuggad med eller utan kanter, trådram, eller med dolda kanter. Använd trådram för att se in i modellen, skuggad för ett rent utseende.", "history.sketch_under_constrained": "Underbestämd ({count} frihetsgrader)", "history.sketch_under_constrained_one": "Underbestämd ({count} frihetsgrad)", "hole_presets.confirm_delete": "Bekräfta borttagning", @@ -1615,6 +1867,10 @@ "material.selection.mixed": "Olika material", "measure.at": "Vid", "measure.item": "Objekt {number}", + "measure.quick_copied": "Mätningen kopierad", + "measure.quick_copy": "Klicka för att kopiera mätningen", + "measure.quick_edges": "{count} kanter", + "measure.quick_faces": "{count} ytor", "messages.ago_h": "för {n} h sedan", "messages.ago_min": "för {n} min sedan", "messages.ago_s": "för {n} s sedan", @@ -1767,6 +2023,11 @@ "not a move": "inte en förflyttning", "not a number": "inte ett tal", "open a sketch first": "öppna en skiss först", + "parameters.apply_live": "Tillämpa varje ändring när du lämnar dess fält", + "parameters.apply_live_tip": "Av: ändringar väntar tills du trycker på Tillämpa eller OK, och sedan byggs konstruktionen om en gång för alla. Avbryt eller Escape förkastar dem.", + "parameters.held": "{count} ändringar väntar på Tillämpa", + "parameters.held_hint": "Redigera uttryck och enheter. Ändringar väntar på Tillämpa eller OK.", + "parameters.held_one": "1 ändring väntar på Tillämpa", "parts.automatic_name": "automatisk", "parts.dimension.across_flats": "Nyckelvidd", "parts.dimension.bore": "Håldiameter", @@ -1789,6 +2050,8 @@ "parts.family.set_screw": "Stoppskruv med insex, plan ände", "parts.family.socket_head_cap_screw": "Cylinderskruv med insex", "parts.family.washer": "Plan bricka", + "parts.holes": "Hål (valfritt)", + "parts.placement_hint": "välj ett eller flera hål för att sätta en del i varje, eller OK placerar den i origo", "pattern.alignment_2": "Justering 2", "pattern.alignment_hint": "Antalet inkluderar originalet. Centrera balanserar kopiorna; Vänd väljer den extra sidan för jämna totaler.", "pattern.direction_1": "Riktning 1", @@ -1955,6 +2218,8 @@ "prefs.origin_plane_names": "Namn på origoplan", "prefs.origin_plane_names_note": "Namn som visas i webbläsaren, dialoger och listor. {top} är XY, {front} är XZ och {right} är YZ, med Z uppåt. Kommandon, skript och sparade konstruktioner behåller XY, XZ och YZ.", "prefs.overall_speed": "Total hastighet", + "prefs.parameter_list_rows": "Rader i parameterlistan", + "prefs.parameter_list_rows_tip": "Nedåtpil i ett tomt värdefält listar parametrarna, favoriter först, upp till så här många. Färre håller listan lätt i en stor konstruktion.", "prefs.persistent_ribbon_pressed": "Framhäv aktiva kommandon i menyfliksområdet", "prefs.persistent_ribbon_pressed_help": "Framhäv kommandon i menyfliksområdet medan deras effekter är aktiva. Stäng av för att behålla det tidigare neutrala utseendet och kommandohjälpen.", "prefs.perspective_fov": "Synfält", @@ -1984,6 +2249,8 @@ "prefs.quality_fastest": "snabbast", "prefs.quality_slowest": "långsammast", "prefs.quality_smoother": "jämnare", + "prefs.quick_measure": "Snabbmätning i vyns hörn", + "prefs.quick_measure_note": "Markeringens storlek i vyns nedre högra hörn: en kants längd, eller Ø och R för en rund kant, en ytas area, en punkts läge, avstånd och vinkel mellan två objekt, summan för flera kanter eller ytor. Ett klick kopierar värdet.", "prefs.render_previous_geometry_budget": "Använd tidigare geometribudget för renderingsförhandsvisning", "prefs.render_previous_geometry_budget_help": "Återställ den tidigare fasta geometrigränsen (512 MiB på 64-bitars dator, 128 MiB i övrigt) i stället för den enhetsbaserade budgeten.", "prefs.reports_saved": "{n} sparade", @@ -2029,6 +2296,8 @@ "prefs.sketch_tab_always_note": "Fliken Skiss ligger kvar i menyfliksområdet, direkt efter Modell, medan ingen skiss är öppen. Dess ritverktyg startar då en skiss på det markerade planet eller den markerade ytan, eller på det plan du väljer härnäst, med verktyget redo i den.", "prefs.sketch_tracking_guides": "Hjälplinjer för justering mot centrum och mittpunkt", "prefs.sketch_tracking_guides_note": "Håll muspekaren över en cirkels centrum eller en rak kants mittpunkt och flytta sedan vågrätt eller lodrätt för att visa en tillfällig prickad hjälplinje.", + "prefs.sketch_tracking_points": "Hjälplinjer för justering från ändpunkter och hörn under ritning", + "prefs.sketch_tracking_points_note": "När ett ritverktyg väntar på en punkt och du pekar på änden av en linje eller båge, ett rektangelhörn eller en fri punkt visas samma tillfälliga prickade hjälplinje som för ett centrum eller en mittpunkt. Av: bara centrum och mittpunkter.", "prefs.sketching": "Skissning", "prefs.snap_drawing_note": "När du ritar eller drar en skisspunkt.", "prefs.source": "Källa", @@ -2056,6 +2325,16 @@ "prefs.theme_follow_off": "Av", "prefs.theme_follow_stop": "Sluta följa", "prefs.theme_import_hint": "Klicka för att välja en temafil, släpp en här eller tryck Ctrl+V (Cmd+V på Mac) med ett tema kopierat", + "prefs.theme_option_opacity": "Opacitet", + "prefs.theme_option_opacity_help": "Justera opaciteten för flytande paneler. En lägstanivå för läsbarhet skyddar text över färgerna i vyfönstret.", + "prefs.theme_option_tab_colours": "Flikfärger", + "prefs.theme_option_tab_colours_help": "Använd det här temats panel- och vyfönsterfärger för varje flik i menyfliksområdet.", + "prefs.theme_option_tab_fade_ms": "Flikövergång", + "prefs.theme_option_tab_fade_ms_help": "Tona över panel- och vyfönsterfärger när du byter flik. Noll byter direkt.", + "prefs.theme_options_badge": "Alternativ", + "prefs.theme_options_more": "Fler · {count}", + "prefs.theme_options_reset": "Återställ", + "prefs.theme_options_unavailable": "Inte tillgängligt på den här enheten", "prefs.theme_paste": "Klistra in tema", "prefs.theme_paste_none": "Urklippet innehåller inget SolidCraft-tema", "prefs.timeline_half_hour": "−30 min", @@ -2077,6 +2356,8 @@ "prefs.under_cursor": "Under markören", "prefs.units_precision": "Enheter och precision", "prefs.updates": "Uppdateringar", + "prefs.value_boxes_in_view": "Visa värden bredvid sina handtag i vyn", + "prefs.value_boxes_in_view_tip": "Ett kommandos värde (Avstånd, Radie…) i en ruta bredvid dess handtag, över modellen. Av: du skriver det i kommandofönstret, som tar över tangentbordet efter ett val, och ingenting täcker modellen.", "prefs.view": "Vybeteende", "prefs.view_confirm_corner": "OK och Avbryt i vyns hörn", "prefs.view_confirm_corner_note": "Medan en dialog eller en skiss är öppen gör en bock och ett kryss uppe till höger i vyn samma sak som dialogens OK och Avbryt, eller slutför och avbryter skissen. Vynavigatorn flyttas ned under dem.", @@ -2086,6 +2367,12 @@ "prefs.viewport_background": "Bakgrund", "prefs.viewport_background_colour": "Färg", "prefs.viewport_background_theme": "Temats gradient", + "prefs.viewport_section_status": "Visa sektionsstatus i vyfönstret", + "prefs.viewport_section_status_help": "Visa sektionsinställningen i vyfönstret. Escape och den aktiva knappen i menyfliksområdet stänger av den. Inaktivera för att behålla de tidigare sektionskontrollerna.", + "prefs.viewport_status_details": "Direkta detaljer i vyfönstret", + "prefs.viewport_status_details_help": "Redigera aktiva analys- och sektionsinställningar i vanliga detaljkort. Inaktivera för att återställa skrivskyddade teckenförklaringar för analys och dialogrutan Sektion.", + "prefs.viewport_status_summary": "Vik ihop statusobjekt i vyfönstret", + "prefs.viewport_status_summary_help": "Vik ihop tre eller fler statusobjekt i vyfönstret till en enda sammanfattningsbricka. Inaktivera för att behålla varje objekt i hela raden.", "prefs.wheel": "Hjul", "prefs.wheel_trackpad": "Hjul och styrplatta", "prefs.workspace": "Arbetsyta", @@ -2964,7 +3251,9 @@ "render.output_ready": "Redo att rendera", "render.output_run_check": "Kör kontroll", "render.output_size_limit": "{method}: upp till {dimension} px per sida och {pixels} pixlar. Största storlek som får plats: {width} × {height}.", + "render.output_size_method_profile": "För att rendera {width} × {height}, välj {method} med CPU-resursprofilen {profile}, eller en mindre storlek.", "render.output_size_ok": "Storleken är OK · {width} × {height} px", + "render.output_size_profile": "För att rendera {width} × {height}, välj CPU-resursprofilen {profile}, eller en mindre storlek.", "render.output_swap_orientation": "Byt bredd och höjd", "render.output_tools_inspector": "Redigera storlek och format i inspektören Utdata.", "render.output_view_image": "Visa bild", @@ -3177,6 +3466,7 @@ "render.size_custom": "Egen…", "render.size_full_hd": "Full HD", "render.size_hd": "HD", + "render.size_needs_profile": "{size} · kräver {profile}", "render.size_presets": "Storleksförinställningar", "render.size_presets_pending": "Kommer med utdataredigeraren", "render.size_preview": "Förhandsvisning", @@ -3464,6 +3754,24 @@ "sheet.flange_type_contour": "Kontur", "sheet.flange_type_edge": "Kant", "sheet.no_body": "det finns ingen plåtkropp: starta en med Fläns > Bas, eller gör om en platta till plåt med Konvertera till plåt (markera dess stora plana yta)", + "sheet.personal_rules.confirm_delete": "Bekräfta borttagning", + "sheet.personal_rules.delete": "Ta bort", + "sheet.personal_rules.delete_prompt": "Ta bort den här personliga regeln? Regler som redan finns i konstruktioner ändras inte.", + "sheet.personal_rules.full": "Det personliga biblioteket innehåller redan 64 regler.", + "sheet.personal_rules.invalid_name": "Ange ett kort namn utan radbrytningar eller styrtecken.", + "sheet.personal_rules.invalid_values": "Fyll i regelns värden: varje värde måste gå att räkna ut i den här konstruktionen, med en positiv tjocklek och en K-faktor från 0 till 1.", + "sheet.personal_rules.missing": "Den valda personliga regeln finns inte längre.", + "sheet.personal_rules.name": "Regelnamn", + "sheet.personal_rules.name_exists": "Det finns redan en personlig regel med det här namnet. Använd ett annat namn.", + "sheet.personal_rules.name_hint": "Ett unikt namn", + "sheet.personal_rules.overwrite": "Spara ändringar i vald personlig regel", + "sheet.personal_rules.rename": "Byt namn", + "sheet.personal_rules.rule": "Personlig regel", + "sheet.personal_rules.save": "Spara som ny", + "sheet.personal_rules.scope": "Sparas i din profil för alla konstruktioner.", + "sheet.personal_rules.select": "Välj en regel", + "sheet.personal_rules.title": "Personliga regler", + "sheet.personal_rules.unsupported": "Den här regeln har värden som den här versionen inte kan använda.", "shortcut.json_error": "parametrar måste vara JSON: {error}", "shortcut.no_command": "inget kommando `{id}`; texten före JSON måste vara ett kommando-id", "shortcut.pin": "Fäst överst i S-rutan", @@ -3500,6 +3808,7 @@ "sketch_text.align_right": "Höger", "sketch_text.bold": "Fet", "sketch_text.builtin_font": "{family} (inbyggd)", + "sketch_text.formula": "Börja med = för att bygga orden av parametrar: ='W' + number(Width)", "sketch_text.italic": "Kursiv", "sketch_text.loading_fonts": "Söker efter installerade teckensnitt…", "sketch_text.on_path": "Texten följer sin bana: kurvan placerar den", @@ -3640,6 +3949,8 @@ "ui.3d_mouse_section": "3D-mus", "ui.3d_mouse_show_in_view": "Visa 3D-musen i 3D-vyn", "ui.3d_mouse_show_in_view_tip": "En bild av din 3D-mus i nedre vänstra hörnet som rör sig när du rör den.", + "ui.3d_mouse_sketch_orbit": "Vrid skisser med 3D-musen", + "ui.3d_mouse_sketch_orbit_tip": "3D-musen vrider också vyn medan en skiss är öppen och ses rakt framifrån, så som musen kan. Av: den panorerar och zoomar skissen tills du lutar vyn.", "ui.3d_mouse_skip": "Min enhet kan inte göra det här", "ui.3d_mouse_skip_tip": "Hoppar över den här rörelsen i båda riktningarna, och kalibreringen utelämnar den.", "ui.3d_mouse_speed": "3D-musens hastighet", @@ -3708,6 +4019,7 @@ "ui.agent_chat_auth_terminal": "Kör detta i en terminal och tryck sedan på Anslut igen:", "ui.agent_chat_auth_title": "Logga in hos agenten", "ui.agent_chat_cancel": "Avbryt", + "ui.agent_chat_cannot_start": "Installerad, men den startar inte: {error}", "ui.agent_chat_changes": "Ändringar: {n}, ett ångrasteg", "ui.agent_chat_changes_n": "{n} ändringar", "ui.agent_chat_changes_of": "{n}/{limit} ändringar", @@ -3800,9 +4112,12 @@ "ui.agent_chat_queued_stopped": "Köad (stoppad, ej skickad)", "ui.agent_chat_refresh": "Uppdatera", "ui.agent_chat_refused": "Inte tillåtet för agenten: {reason}", + "ui.agent_chat_reinstall": "{agent} är installerad ({program}), men startade inte när SolidCraft kontrollerade den: {error}\nInstallationen kan vara ofullständig, eller gjord för en annan typ av dator. Installera den igen med `{install}`, kontrollera att den skriver ut sin version i en terminal och tryck sedan på Uppdatera.", "ui.agent_chat_remove": "Ta bort från kön", "ui.agent_chat_remove_file": "Ta bort det från det här meddelandet", "ui.agent_chat_remove_imported": "Ta bort det från det här meddelandet; delen finns kvar i konstruktionen (Ångra tar bort den)", + "ui.agent_chat_reply_cut_off": "Svaret nådde sin längdgräns innan det var klart och kan därför vara ofullständigt. Be agenten fortsätta, eller be om ett kortare svar.", + "ui.agent_chat_reply_declined": "Modellen avböjde att svara på det här meddelandet.", "ui.agent_chat_retry": "Försök igen", "ui.agent_chat_right": "Höger", "ui.agent_chat_save": "Spara", @@ -3861,12 +4176,17 @@ "ui.ai_copy_suffix": "kopia", "ui.ai_custom_url": "Egen URL", "ui.ai_default": "Standard", + "ui.ai_edit": "Redigera", + "ui.ai_edit_agent_title": "Redigera agent", + "ui.ai_edit_gone": "Den här agenten togs bort, så det finns inget att spara.", + "ui.ai_edit_tip": "Ändra den här agentens namn, kommando eller slutpunkt. En sparad nyckel förblir dold.", "ui.ai_http_busy": "slutpunkten är upptagen eller över sin gräns; försök igen senare", "ui.ai_http_key": "slutpunkten nekade nyckeln: kontrollera den under Inställningar > AI", "ui.ai_http_not_found": "ingen sådan slutpunkt eller modell: kontrollera bas-URL:en och modellnamnet", "ui.ai_http_too_large": "konversationen är för stor för den här slutpunkten; starta en ny chatt", "ui.ai_key": "API-nyckel", "ui.ai_key_hint": "valfritt; lagras i systemets nyckelring", + "ui.ai_key_keep_hint": "lämna tomt för att behålla den sparade nyckeln", "ui.ai_key_needs_https": "En nyckel skickas endast via HTTPS, eller till den här datorn.", "ui.ai_key_note": "Lagras i systemets nyckelring, aldrig i SolidCrafts inställningar. Lämna tomt för en lokal server.", "ui.ai_keychain_error": "Systemets nyckelring nekade: {error}", @@ -3898,6 +4218,7 @@ "ui.ai_request_timeout": "Tidsgräns för begäran", "ui.ai_request_timeout_hint": "Hur lång tid en modellbegäran till den här slutpunkten får ta, från 30 s till en timme. Öka den för en långsam lokal modell.", "ui.ai_reset": "Återställ", + "ui.ai_save": "Spara", "ui.ai_settings": "AI-inställningar…", "ui.ai_show_features": "Visa AI-funktioner", "ui.ai_show_features_note": "Agentchatten: en panel där en kodningsagent du redan använder, eller en modellslutpunkt du väljer, arbetar med den öppna konstruktionen. Av: ingen AI-knapp, genväg, menypost eller panel, och inget söks eller startas.", @@ -4071,6 +4392,40 @@ "ui.design_properties.project": "Projekt", "ui.design_properties.revision": "Revision", "ui.design_saved": "Sparad i {path}", + "ui.details_access_caption": "Ytorna visar om ett verktyg kan nå dem från den valda riktningen.", + "ui.details_angle": "Vinkel", + "ui.details_continuity_caption": "Redigera den markerade skarven. Andra skarvar behåller sina egna inställningar.", + "ui.details_continuity_stale": "Tidigare geometri: kör analysen igen för att redigera eller rama in den här skarven.", + "ui.details_copy_list": "Kopiera lista", + "ui.details_curvature": "Krökningstolerans", + "ui.details_curvature_caption": "Ytorna färgas efter sin lokala krökningsradie.", + "ui.details_direction": "Verktygsriktning", + "ui.details_draft_caption": "Ytorna färgas efter sin vinkel mot dragriktningen.", + "ui.details_edit_seam": "Redigera skarv", + "ui.details_fixture": "Komponentexemplar — endast exempeldata", + "ui.details_fixture_caption": "Komponentexemplar med illustrativa värden; det här är inte ett modellresultat.", + "ui.details_fixture_problem": "Exempelresultat med ett problem", + "ui.details_fixture_readout": "Exempel på mätvärde", + "ui.details_flip": "Vänd", + "ui.details_gap": "Spalt", + "ui.details_hatch": "Skraffera snittytor", + "ui.details_jump": "Krökningshopp", + "ui.details_key": "Teckenförklaring", + "ui.details_offset": "Förskjutning", + "ui.details_plane": "Plan", + "ui.details_pull": "Dragriktning", + "ui.details_radius": "Radie", + "ui.details_readout": "Avläsning", + "ui.details_samples": "Provpunkter", + "ui.details_seam": "{body} · Kant {edge}", + "ui.details_settings": "Inställningar", + "ui.details_sharp_gap": "Skarp eller spalt", + "ui.details_show_all": "Visa alla {count}", + "ui.details_smooth": "Jämn", + "ui.details_stripes": "Ränder", + "ui.details_target": "Rama in geometrin", + "ui.details_worst_seams": "Sämsta skarvarna", + "ui.details_zebra_caption": "Speglade ränder visar hur jämnt ytorna möts.", "ui.diameter": "Diameter", "ui.dimension": "Mått", "ui.direct_interaction": "Direktinteraktion", @@ -4131,6 +4486,7 @@ "ui.equation_y_of_x": "y(x)", "ui.equivalent_radius": "Ekvivalent radie", "ui.expand": "Expandera", + "ui.explode_needs_two_components": "Sprängvy drar isär komponenter, så konstruktionen behöver två eller fler (Ny komponent, Infoga konstruktion eller Skapa komponenter från kroppar)", "ui.exportDxf": "Exportera DXF…", "ui.export_ellipsis_keymap": "Exportera…", "ui.export_keymap_tip": "Tangenter, S-rutans favoriter och verktygsfältet, som en fil", @@ -4419,12 +4775,17 @@ "ui.plugins_skipped": "{count} mapp(ar) kunde inte läsas.", "ui.plugins_version": "{version}", "ui.point": "Punkt", + "ui.point_cloud_add_filter": "Lägg till filter…", "ui.point_cloud_auto_range": "Automatiskt intervall", "ui.point_cloud_color_by": "Färglägg efter", "ui.point_cloud_file_colors": "Filens färger", + "ui.point_cloud_filter_by": "Filtrera efter", "ui.point_cloud_height_high": "Högst", "ui.point_cloud_height_low": "Lägst", + "ui.point_cloud_legend_filter": "Visas och döljs i teckenförklaringen ovan", "ui.point_cloud_no_file_colors": "Filen har inga färger", + "ui.point_cloud_pause": "Pausa", + "ui.point_cloud_play": "Spela upp", "ui.point_cloud_single_color": "En färg", "ui.point_cloud_size": "Punktstorlek", "ui.point_cloud_units": "Filens enheter", @@ -4453,6 +4814,7 @@ "ui.properties.inertia_y": "Iyx, Iyy, Iyz", "ui.properties.inertia_z": "Izx, Izy, Izz", "ui.properties.measuring": "Mäter…", + "ui.properties.measuring_bodies": "Mäter… {done} av {total} kroppar", "ui.properties.measuring_failed": "Mätningen avbröts innan den var klar.", "ui.properties.principal_axis": "Huvudaxel {number}", "ui.properties.principal_moments": "Huvudtröghetsmoment", @@ -4520,6 +4882,7 @@ "ui.selectOther": "Markera annan…", "ui.selectTangentChain": "Markera tangentkedja", "ui.select_all": "Markera alla", + "ui.select_components_by_face": "Markera komponenter (en yta väljer sin komponent)", "ui.selection": "Markering", "ui.selection_modifiers": "Markeringsmodifierare", "ui.selection_shift_add_only": "Shift+klick lägger till utan att ta bort", @@ -4561,6 +4924,11 @@ "ui.sketch_intersect_selection_filter": "Markeringsfilter", "ui.sketch_modifiers": "Skissmodifierare", "ui.sketch_project_geometry": "Geometri", + "ui.sketch_report.all_constrained": "Allt är fullt definierat.", + "ui.sketch_report.dof": "Frihetsgrader", + "ui.sketch_report.loose": "Inte fullt definierade", + "ui.sketch_report.status": "Status", + "ui.sketch_report.title": "Skissrapport", "ui.sketch_scale": "Skissskala", "ui.sketches": "Skisser", "ui.slice": "Skiva", @@ -4670,7 +5038,14 @@ "ui.viewport_status_key": "Visa teckenförklaring för {name}", "ui.viewport_status_minimum_radius": "Minsta radie", "ui.viewport_status_pairs": "{count} par", + "ui.viewport_status_section_hatch": "{setting} · skraffering", + "ui.viewport_status_section_plane": "Aktuellt plan", + "ui.viewport_status_section_tilted": "lutad · {offset}", "ui.viewport_status_stripes": "{count} ränder", + "ui.viewport_status_summary_clear": "Stäng av allt", + "ui.viewport_status_summary_heading": "I den här vyn", + "ui.viewport_status_summary_label": "{name} och {count} objekt till", + "ui.viewport_status_summary_more": "+{count}", "ui.viewport_theme_follow": "Följ tema", "ui.visual_shaded_hidden_edges": "Skuggad med dolda kanter", "ui.visual_shaded_with_edges": "Skuggad med kanter", diff --git a/assets/locales/tr.json b/assets/locales/tr.json index 87e728d447..2b05990ea2 100644 --- a/assets/locales/tr.json +++ b/assets/locales/tr.json @@ -60,6 +60,7 @@ "BLEND SURFACE": "GEÇİŞ YÜZEYİ", "BOUNDARY SURFACE": "SINIR YÜZEYİ", "BROWSER": "TARAYICI", + "Back to the original order": "Orijinal sıraya dön", "Ball": "Küresel", "Bar": "Çubuk", "Base Center": "Taban Merkezi", @@ -595,6 +596,8 @@ "Snap to face and edge centres": "Yüz ve kenar merkezlerine yapış", "SolidCraft cannot submit reports directly. Submit on GitHub; no token is needed in the app.": "SolidCraft raporları doğrudan gönderemez. GitHub'da gönderin; uygulamada belirteç gerekmez.", "SolidCraft closed before these designs were saved. Their last autosave can be opened.": "SolidCraft bu tasarımlar kaydedilmeden önce kapandı. Son otomatik kayıtları açılabilir.", + "Sort A to Z": "A'dan Z'ye sırala", + "Sort Z to A": "Z'den A'ya sırala", "Spacing 2 must be a non-zero number": "Aralık 2 sıfırdan farklı bir sayı olmalıdır", "Spacing must be a non-zero number": "Aralık sıfırdan farklı bir sayı olmalıdır", "Specialise": "Özelleştir", @@ -819,6 +822,7 @@ "cmd.appearance_pick_up": "Görünümü Al", "cmd.appearance_replace": "Görünümü Değiştir", "cmd.assembly_bom": "Malzeme listesi", + "cmd.assembly_mass_properties": "Kütle özellikleri", "cmd.body.print_face": "Baskı yüzeyi", "cmd.body_rename": "Gövdeyi yeniden adlandır", "cmd.browser_collapse_all": "Tümünü Daralt", @@ -1244,6 +1248,7 @@ "cmd.view.render_browser": "Render tarayıcısı", "cmd.view.render_dock": "Render panelini taşı", "cmd.view.render_focus": "Render odak görünümü", + "cmd.view.status_details": "Görünüm Alanı Durum Ayrıntıları", "cmd.view.status_items": "Görünüm Alanı Durum Öğeleri", "cmd.view_ambient_occlusion": "Ortam tıkanması", "cmd.view_angle_readout": "Görünüm açıları", @@ -1387,21 +1392,168 @@ "from the rule": "kuraldan", "help.apache": "Apache lisansı 2.0", "help.appearance.assign": "Seçili gövdelere, yüzlere veya bileşenlere ağırlıklarını değiştirmeden bir renk veya malzeme görünümü verir. Parçaları ayırt etmek veya bir kaplamayı önizlemek için kullanın.", + "help.assembly.bom": "Tasarımın veya bir bileşenin parçalarını parça numarasına göre adetleriyle listeler ve listeyi CSV ya da JSON olarak kaydedebilir. Sipariş ve montaj için parça listesi olarak kullanın.", "help.attributions": "Atıflar (ATTRIBUTION.md)", "help.build": "Sürüm {version} · commit {commit} · derleme tarihi {date}", + "help.canvas.decal": "Bir görseli modelin düz bir yüzüne çıkartma gibi yerleştirir. Bir parça üzerinde etiket, logo veya baskılı desen göstermek için kullanın.", + "help.canvas.edit": "Bir tuvali değiştirir: konumunu, boyutunu, açısını, opaklığını, aynalamasını, görünürlüğünü veya adını. Referans görseli, üzerinden çizdiğiniz eskizle hizalamak için kullanın.", + "help.canvas.insert": "Bir PNG veya JPEG görseli, saydam bir referans olarak bir düzleme ya da yüze yerleştirir. Görsel bilinen bir ölçüye getirilmiş hâlde, bir fotoğrafın veya çizimin üzerinden eskiz çizmek için kullanın.", + "help.component.break_link": "Bağlı bir bileşeni tasarım dosyasından ayırır, gövdelerini olduğu gibi korur. Parça artık özgün dosyadaki değişiklikleri izlememeliyse kullanın.", + "help.component.capture_position": "Elle taşıdığınız parçaların bulunduğu konumları kalıcı olarak saklar. Hiçbir bağlantının tutmadığı parçaları sürükleyerek yerlerine getirdikten sonra kullanın.", + "help.component.create": "Tasarıma yeni, boş bir bileşen ekler; başka bir üst öğe seçmediğiniz sürece etkin bileşenin içine. Bir montajın yeni parçasına gövdeleri ve eskizleri için ayrı bir yer açmak amacıyla kullanın.", "help.component.finish_edit": "Yerinde düzenlediğiniz parçayı kaydeder, montajına döner ve montajı bu parçadan günceller. Bağlı parçadaki değişiklikleriniz bittiğinde kullanın.", + "help.component.from_bodies": "Seçili her gövdeyi, gövdenin adını taşıyan kendi bileşenine dönüştürür. Ayrı gövdelerden kurulmuş bir tasarım, parçaları birbirine bağlanıp taşınabilen bir montaja dönüşecekse kullanın.", + "help.component.insert_design": "Kaydedilmiş başka bir tasarımı, dosyasına bağlı bir bileşen olarak ekler; katı gövdeleri oradaki gibi yerleştirilir. Bir parçayı montajda yeniden kullanmak ve sonraki değişiklikleri Bağları güncelle ile almak için kullanın.", + "help.component.properties": "Bir bileşenin hacmini, kütlesini, kütle merkezini ve eylemsizlik momentlerini gövdelerinin malzemelerine göre gösterir. Bir montajın ağırlığını ve dengesini kontrol etmek için kullanın.", + "help.component.revert_position": "Elle taşıdığınız parçaları eski yerlerine geri koyar; yakalanmamış taşımaları atar. Bir montajdaki deneme taşımalarını geri almak için kullanın.", + "help.component.update_links": "Bağlı tasarım dosyalarını yeniden okur ve değişen yerlerdeki gövdeleri değiştirir. Bu montajın kullandığı bir parçayı düzenledikten sonra kullanın.", + "help.config.configure": "Parametreleri, özellikleri, malzemeleri veya görünümleri konfigürasyon tablosuna sütun olarak ekler. Bir tasarımın, örneğin bir parçanın farklı boyutları gibi varyantlarını oluşturmak için kullanın.", + "help.config.preview": "Konfigürasyon tablosundaki bir satırın yeniden oluşturulabildiğini, tasarımı veya geçmişini değiştirmeden kontrol eder. Emin olmadığınız bir varyanta geçmeden önce kullanın.", + "help.config.rules": "Konfigürasyon tablosunun kurallarını, satırlarını ve hangi satırın etkin olduğunu gösterir. Bir tasarımın varyantlarının nasıl kurulduğunu görmek için kullanın.", + "help.config.table": "Konfigürasyon tablosunu gösterir: varyant başına bir satır, parametre veya özellik başına bir sütun ve tasarımın etkin satırla hâlâ uyuşup uyuşmadığı. Bir tasarımın varyantlarını gözden geçirmek için kullanın.", "help.connect_note": "SolidCraft sunucusunu yüklemek için her asistanı yeniden başlatın. Değişmeden önce her dosya .bak olarak yedeklendi.", "help.connect_title": "AI asistanlarını bağla", + "help.construct.axis.edge": "Düz bir kenar veya eskiz çizgisi boyunca bir yapı ekseni oluşturur. Döndürme, dairesel desen veya bağlantı için eksen olarak kullanın.", + "help.construct.axis.normal_to_face": "Bir noktadan geçen ve bir yüze dik duran bir yapı ekseni oluşturur. Bir yüzeyden dosdoğru çıkan bir eksen olarak kullanın.", + "help.construct.axis.through_cylinder": "Bir silindir, koni veya torus yüzünün ya da dairesel bir kenarın merkezinden geçen bir yapı ekseni oluşturur. Özellikleri yuvarlak bir parçanın etrafında döndürmek veya çoğaltmak için kullanın.", + "help.construct.axis.two_planes": "İki düzlemin veya düz yüzün buluştuğu yerde bir yapı ekseni oluşturur. İhtiyacınız olan eksen iki yüz arasındaki çizgiyse kullanın.", + "help.construct.axis.two_points": "İki noktadan, köşe noktasından veya eskiz noktasından geçen bir yapı ekseni oluşturur. Bilinen iki konum arasında uzanan bir eksen için kullanın.", + "help.construct.plane.along_path": "Bir kenara veya eğriye dik, onun boyunca bir konumda yapı düzlemi oluşturur. Süpürme için bir profili ya da bir yolun kesitini eskizlemek için kullanın.", + "help.construct.plane.at_angle": "Bir düzlemden veya yüzden, bir eksen ya da kenar etrafında belirli bir açıyla döndürülmüş yapı düzlemi oluşturur. Açılı bir köşebent gibi eğimli eskizler için kullanın.", + "help.construct.plane.midplane": "İki düzlemin veya düz yüzün tam ortasında, bir açıyla buluşuyorlarsa onları ikiye bölen yapı düzlemi oluşturur. Aynalama için simetri düzlemi olarak kullanın.", + "help.construct.plane.offset": "Bir düzleme veya düz yüze paralel, belirli bir mesafede ya da bir noktadan geçen yapı düzlemi oluşturur. Bir yüzün belirli bir yükseklik üstünde eskiz çizmek için kullanın.", + "help.construct.plane.perpendicular": "Bir çizgiyi veya kenarı içeren ve bir düzleme ya da yüze dik duran yapı düzlemi oluşturur. Bir modelde kenar boyunca dik duran eskizler çizmek için kullanın.", + "help.construct.plane.tangent": "Bir silindire, koniye, küreye veya torusa bir noktada değen yapı düzlemi oluşturur. Yuvarlak bir parçanın yan tarafına eskiz çizmek için kullanın.", + "help.construct.plane.three_points": "Üç noktadan geçen bir yapı düzlemi oluşturur. Modeldeki konumlarla, örneğin üç köşeyle belirlenen bir düzlem için kullanın.", + "help.construct.plane.two_edges": "Aynı düzlemde bulunan iki düz kenardan veya çizgiden geçen yapı düzlemi oluşturur. Bir parçayı boydan boya kesen, örneğin iki karşılıklı kenardan geçen bir düzlem için kullanın.", + "help.construct.point.along_path": "Bir kenar veya eğri boyunca bir konumda yapı noktası oluşturur. Bir yolun ara bir yerinde referans olarak kullanın.", + "help.construct.point.center": "Dairesel bir kenarın, eskiz dairesinin veya yayının, kürenin ya da torusun merkezinde yapı noktası oluşturur. Delikler, eksenler ve bağlantılar için referans olarak kullanın.", + "help.construct.point.edge_plane": "Bir kenarın veya çizginin bir düzlemden ya da düz yüzden geçtiği yerde yapı noktası oluşturur. Bir yolun düzlemi nerede kestiğini işaretlemek için kullanın.", + "help.construct.point.three_planes": "Üç düzlemin veya düz yüzün buluştuğu yerde yapı noktası oluşturur. Modelde köşe noktası olmasa bile köşe referansı olarak kullanın.", + "help.construct.point.two_edges": "İki kenarın veya çizginin buluştuğu ya da birbirine en çok yaklaştığı yerde yapı noktası oluşturur. Sanal bir köşede referans olarak kullanın.", + "help.construct.point.vertex": "Bir köşe noktası veya eskiz noktası üzerinde yapı noktası oluşturur. Çevresindeki eskiz ya da gövde gizlense bile kalan bir referans olarak kullanın.", + "help.contact.check": "Temas kümelerindeki parçalardan, mevcut konumlarında birbirinin içinden geçen çiftleri listeler. Bağlantıları tahrik etmeden veya hareket etüdü çalıştırmadan önce çarpışmaları bulmak için kullanın.", + "help.contact.create": "İki veya daha fazla parçayı, bağlantılar hareket ederken gövdelerinin birbirinin içinden geçmesi engellenen bir temas kümesinde toplar. Dokunduklarında durması gereken parçalar için kullanın, örneğin dayanağına dayanan bir kol.", + "help.contact.disable_all": "Teması kapatır; böylece tahrik edilen bağlantılar ve hareket etütleri parçaların dokunduğu yerde artık durmaz. Parçaları yeniden serbestçe hareket ettirmek veya büyük bir montajı akıcı tutmak için kullanın.", + "help.contact.edit": "Bir temas kümesini değiştirir: içerdiği parçaları, adını veya bastırılıp bastırılmadığını; kümeyi silebilir de. Hangi parçaların birbirinin içinden geçmesinin engelleneceğini ayarlamak için kullanın.", + "help.contact.enable_all": "Montajın her parçasını tek bir temas kümesine koyar; böylece bağlantılar hareket ederken hiçbir iki parça birbirinin içinden geçemez. Bütün bir mekanizmayı hızlıca denetlemek için kullanın.", + "help.contact.enable_sets": "Tanımladığınız temas kümeleri için teması açar; böylece tahrik edilen bağlantılar ve hareket etütleri, parçaların birbirinin içinden geçeceği yerde durur. Temas kapatıldıktan sonra kümelerinizi geri getirmek için kullanın.", + "help.contact.list": "Tasarımın temas kümelerini, her birindeki parçaları ve temasın açık olup olmadığını listeler. Montajı tahrik etmeden önce temasın nasıl kurulduğunu kontrol etmek için kullanın.", + "help.document.inspect": "Tasarıma genel bir bakış gösterir: bileşenlerini, gövdelerini, eskizlerini, özelliklerini ve parametrelerini. Kendinizin kurmadığı bir tasarımda yolunuzu bulmak için kullanın.", + "help.document.units": "Tasarımın uzunlukları hangi birimde göstereceğini değiştirir, örneğin mm veya inç. Geometri boyutunu korur; yalnızca görünüm ve düz sayılar buna uyar.", + "help.edit.checkpoint": "Tasarımı, etkin eskizi ve geri alma geçmişini şu anki hâliyle hatırlar. Geri dönmek isteyebileceğiniz riskli bir şey denemeden önce kullanın.", + "help.edit.restore_checkpoint": "Geri alma geçmişi dahil, bir kontrol noktasına geri döner. Kontrol noktasından beri yapılan her şeyi tek adımda atmak için kullanın.", + "help.explode.create": "Parçaları montajın merkezinden uzağa iten ya da belirlediğiniz taşımalarla ayıran bir patlatılmış görünüm oluşturur. Bir montajın nasıl birleştiğini talimatlarda veya sunumlarda göstermek için kullanın.", "help.face.delete": "Seçili yüzleri kaldırır ve komşu yüzlerin boşluğu kapatmasını sağlar ya da boşluğu yüzey olarak açık bırakır. Yuvarlatılmış bir kenarı, bir deliği veya yolda duran bir çıkıntıyı gidermek için kullanın.", + "help.file.insert_mesh": "Bir 3MF, STL, OBJ, PLY veya glTF dosyasını tasarıma, yeni bir bileşende ağ gövdeleri olarak ekler. İndirilmiş veya taranmış bir modeli referans olarak tasarımınızın yanına koymak için kullanın.", + "help.file.insert_step": "Bir STEP, IGES, FreeCAD veya Rhino dosyasını tasarıma, yeni bir bileşende katı gövdeler olarak ekler; bir montajın parçaları iç içe bileşenler olur. Tedarikçilerden veya diğer CAD programlarından modeller getirmek için kullanın.", + "help.file.save_mesh": "Gövdeleri, eğri yüzlerin pürüzsüzlüğünü seçerek STL, 3MF veya OBJ ağı olarak kaydeder. Parçaları 3B baskıya ya da ağ okuyan programlara hazırlamak için kullanın.", + "help.file.send_to_slicer": "Seçili gövdeleri 3MF dosyası olarak kaydeder ve seçtiğiniz dilimleyicide açar. Modelden baskıya tek adımda geçmek için kullanın.", "help.font_details": "Arayüz, egui'nin yerleşik yazı tiplerini kullanır (Ubuntu Font Licence ve MIT/Bitstream Vera lisansları altında Ubuntu Light ve Hack; OFL ve MIT altında Noto Emoji ve emoji simge yazı tipi). Eskiz metni Liberation Sans (SIL OFL 1.1) kullanır. The Crafting Apps'in isteğe bağlı yazı tipi paketi craft-fonts (BIZ UDPGothic, Shippori Mincho, BIZ UDMincho, Noto Sans CJK SC, Noto Sans Arabic; SIL OFL 1.1), SolidCraft derlemelerine dahil değildir.", "help.fonts": "Yazı tipleri", + "help.frame.member": "Bir profili, örneğin bir boruyu veya alüminyum ekstrüzyon profilini, bir çerçeve eskizinin çizgileri boyunca, çizgi başına bir eleman olacak şekilde yerleştirir. Çerçeveler, raflar ve kaynaklı yapılar kurmak için kullanın.", + "help.inspect.accessibility": "Bir aletin tek bir yönden ulaşabildiği yüzleri ve ulaşamadıklarını renklendirir. Bir parçayı o taraftan işleme veya kalıplama açısından kontrol etmek için kullanın.", + "help.inspect.center_of_mass": "Gövdelerin kütle merkezini, tek tek ve hepsi birlikte, düzgün yoğunlukla gösterir. Dengeyi ve bir parçanın nereye devrileceğini kontrol etmek için kullanın.", + "help.inspect.colored_components": "Her bileşene, görünümleri değiştirmeden geçici kendi rengini verir. Bir montajın parçalarını bir bakışta ayırt etmek için kullanın.", + "help.inspect.compare": "İki gövdeyi karşılaştırır ve birinde olup diğerinde olmayan malzemenin nerede olduğunu hacim ve sapmayla gösterir. Değiştirilmiş veya içe aktarılmış bir parçayı orijinaliyle karşılaştırıp kontrol etmek için kullanın.", + "help.inspect.continuity": "Yüzlerin kenarları boyunca ne kadar düzgün buluştuğunu denetler: boşlukları, kırıkları veya eğrilik sıçramalarını; birleşim yerlerini renklendirir. Yüzeylerin nerede temiz birleşmediğini bulmak için kullanın.", + "help.inspect.curvature_comb": "Eğriler, kenarlar veya yüzler boyunca, dişleri ne kadar güçlü eğildiklerini gösteren taraklar çizer. Düzleşmiş yerleri, kırıkları ve düzensiz eğriliği fark etmek için kullanın.", + "help.inspect.curvature_map": "Yüzleri ne kadar güçlü eğrildiklerine göre renklendirir; yarıçapın küçük olduğu yerler kırmızıdır. Dar noktaları bulmak ve bir yüzeyin ne kadar pürüzsüz olduğunu değerlendirmek için kullanın.", + "help.inspect.draft": "Yüzleri, kalıptan çıkarma yönüne göre koniklik açılarına göre renklendirir ve yetersiz olanları işaretler. Bir parçayı kalıplamadan veya dökmeden önce kontrol etmek için kullanın.", + "help.inspect.environment_map": "Modeli yansıtıcı, ayna gibi bir yüzeyle gösterir. Yüzeylerin akışını, yansımaların üzerlerinden nasıl geçtiğine bakarak değerlendirmek için kullanın.", + "help.inspect.interference": "Örtüşen gövdeleri bulur ve her örtüşmeyi hacmiyle gösterir. Bir montajda çarpışan parçalar olup olmadığını kontrol etmek için kullanın.", + "help.inspect.isocurve": "Yüzlerin izoparametrik eğrilerini, yani yüzeyin üzerine kurulduğu ızgarayı çizer. Bir yüzeyin nasıl aktığını ve nerede sıkıştığını görmek için kullanın.", + "help.inspect.measure": "Seçtiğiniz öğeler arasındaki uzunlukları, mesafeleri, açıları, alanları ve yarıçapları ya da gövdelerin hacmini ve kütlesini ölçer. Boyutları ve boşlukları kontrol etmek için kullanın.", + "help.inspect.minimum_radius": "Eğrilerin, kenarların veya yüzlerin en küçük eğrilik yarıçapını ve nerede olduğunu bulur. Bir aletin veya nozulun şekli izleyip izleyemeyeceğini kontrol etmek için kullanın.", + "help.inspect.section": "Görünümü bir düzlem boyunca keserek açar ve kesilen yüzleri tarar; modeli değiştirmez. Parçaların ve montajların içine bakmak için kullanın.", + "help.inspect.zebra": "Modelin yüzlerinde yansıyan siyah beyaz şeritleri gösterir. Pürüzsüzlüğü değerlendirmek için kullanın: bir kenarda kopan veya kırılan şeritler zayıf bir geçişe işaret eder.", + "help.joint.as_built": "İki parçayı, ikisini de oynatmadan bulundukları yerde birbirine bağlar ve hâlâ nasıl hareket edebileceklerini belirler. Son konumlarına zaten yerleştirdiğiniz parçalar için kullanın.", + "help.joint.cam": "Dönen bir kamı bir izleyiciye bağlar; kamın dış hattı altından geçerken izleyici kayar veya salınır. Kamlar, eksantrikler ve supap tahrikleri için kullanın.", + "help.joint.create": "İki parçayı seçtiğiniz bağlantı orijinlerinde birleştirir ve ikinci parçayı yerine taşır; bağlantı türü, hâlâ nasıl hareket edebileceklerini belirler. Parçaları gerçek mekanizma gibi hareket eden bir montaj kurmak için kullanın.", + "help.joint.drive": "Bir bağlantının açısını veya mesafesini ayarlar ve parçaları sınırları içinde onunla birlikte hareket ettirir; bağlı bağlantılar ve kapalı döngüler de buna uyar. Bir mekanizmanın istendiği gibi hareket ettiğini kontrol etmek için kullanın.", + "help.joint.gear": "Dönen iki bağlantıyı bir dişli oranıyla bağlar; birini sürmek diğerini döndürür. Düz ve iç dişliler için kullanın; oran, adım yarıçaplarından veya diş sayılarından alınır.", + "help.joint.limits": "Bir bağlantının ne kadar dönebileceğini veya kayabileceğini en az ve en çok olarak belirler ya da bu sınırları temizler. Bir menteşeyi, kayar bağlantıyı veya kolu gerçek hareket aralığında tutmak için kullanın.", + "help.joint.motion_link": "İki bağlantıyı bir oran ve bir ofsetle eşler; birini sürmek diğerini hareket ettirir. Dişli, Kremayer ve pinyon veya Vida'nın kapsamadığı, bağlantılar arasındaki sabit bir ilişki için kullanın.", + "help.joint.origin": "Bir parça üzerine adlandırılmış bir bağlantı orijini yerleştirir: bir yüzün veya yuvarlak bir ucun merkezine, bir kenarın ortasına, bir köşeye ya da bir noktaya. Bağlantıların nereye tutunacağını hazırlamak için kullanın; bir yüzde seçilen orijin, parça boyut değiştirdiğinde yüzü izler.", + "help.joint.rack_pinion": "Dönen bir pinyonu kayan bir kremayere bağlar; kremayer, pinyonun üzerinde yuvarlandığı adım dairesi uzunluğu kadar hareket eder. Kremayer tahrikleri, direksiyon ve doğrusal tablalar için kullanın.", + "help.joint.reach": "Bir parça üzerindeki bir noktayı, zincirindeki bağlantıları sınırları içinde değiştirerek bir hedefe doğru taşır. Bir mekanizmayı, ucunun olması gereken yere göre konumlandırmak için kullanın, örneğin bir robot kolunun ucu.", + "help.joint.rigid_group": "Seçili parçaları olduğu gibi birbirine kilitler; böylece tek parça olarak hareket ederler. Birbirine ait olan ve aralarında hareket bulunmayan parçalar için kullanın.", + "help.joint.screw": "Dönmeyi ve kaymayı bir adımla bağlar; her tur o mesafe kadar ilerler. Hareket vidaları, cıvatalar ve dişli miller için kullanın.", + "help.library.apply": "Yerel bir kitaplık dosyasındaki bir malzemeyi veya görünümü gövdelere, yüzlere ya da bileşenlere uygular. Başka bir tasarımdan kaydettiğiniz malzemeleri tekrar uygulamak için kullanın.", + "help.library.export": "Tasarımın malzemelerini ve görünümlerini bir kitaplık dosyasına kaydeder. Bunları paylaşmanız ya da başka tasarımlarda yeniden kullanmanız gerekiyorsa kullanın.", + "help.library.inspect": "Bir kitaplık dosyasını denetler ve içindeki malzemeleri ile görünümleri, tasarımı değiştirmeden listeler. Uygulamadan önce kitaplığın neler içerdiğini görmek için kullanın.", "help.licences": "Lisanslar", "help.material.assign": "Seçili gövdelerin neyden yapıldığını belirler: çelik, alüminyum veya ABS gibi. Kütle ve ağırlığın gerçek parçayla uyuşması için kullanın.", + "help.material.properties.validate": "Bir malzeme veri kartını tutarlı sayılar ve eksik değerler açısından kontrol eder. Verilere kütle veya rijitlik için güvenmeden önce kullanın.", + "help.mesh.convert": "STL gibi bir ağ gövdesini, düz ve silindirik yüzlerden oluşan düzenlenebilir bir katı olarak yeniden kurar. İçe aktarılmış mekanik parçaları modellenmiş bir gövde gibi değiştirmek için kullanın.", "help.mit": "MIT lisansı", + "help.motion.study": "Bağlantı değerlerini bir zaman çizelgesi boyunca adımlarda ayarlar ve animasyon olarak oynatır. Bir mekanizmanın bir dizi boyunca hareketini izlemek ve yol boyunca açıklıkları kontrol etmek için kullanın.", + "help.motion.trace": "Bir hareket etüdü oynatılırken veya bir bağlantı sürülürken bir parça üzerindeki bir noktanın izlediği yolu çizer ve onayladığınızda 3B eskiz eğrisi olarak saklar. Bir mekanizmanın nereye eriştiğini görmek veya yolu üzerine bir şey kurmak için kullanın.", "help.notice": "NOTICE", + "help.occurrence.ground": "Bir parçayı yerine sabitler ya da yeniden serbest bırakır; böylece bağlantılar diğer parçaları onun etrafında hareket ettirir. Montajın geri kalanının üzerine kurulduğu çerçeve veya taban için kullanın.", "help.occurrence.move": "Montaj parçasını yalnızca eklemlerinin izin verdiği serbestliklerde taşır. Bir parça yüzü seçtikten sonra kullanılabilir oku veya yayı sürükleyin.", "help.parameters.change": "Tasarımın ölçülerini yöneten adlandırılmış değerlerin ve ifadelerin tablosunu açar. Bir ölçüyü tek yerden değiştirip onu kullanan her özelliğin buna uymasını sağlamak için kullanın.", + "help.parameters.export": "Tasarımın parametrelerini CSV veya JSON olarak kaydeder. Bunları bir elektronik tabloda düzenlemek veya başka bir tasarıma taşımak için kullanın.", + "help.parameters.graph": "Hangi parametrelerin ve özelliklerin hangilerini kullandığını, aralarındaki döngüleri veya hataları gösterir. Bir parametreyi değiştirmenin neleri etkileyeceğini görmek için kullanın.", + "help.parameters.import": "Parametreleri CSV veya JSON dosyasından tasarıma okur; ya hepsini ya hiçbirini. Değerleri bir elektronik tablodan veya başka bir tasarımdan getirmek için kullanın.", + "help.parameters.move": "Bir kullanıcı parametresini listede başka birinin önüne veya arkasına taşır. İlgili parametreleri bir arada tutmak için kullanın.", + "help.parts.fastener": "Seçtiğiniz boyutta standart bir vida, cıvata, somun, rondela, pim veya rulman ekler. Bir deliğe yerleştirildiğinde bir bağlantıyla deliğe oturur; böylece bağlantı elemanlarını kendiniz modellemeniz gerekmez.", + "help.parts.insert": "Seçtiğiniz ailede, boyutta ve uzunlukta standart bir parça ekler. Bir deliğin kenarına veya çeperine yerleştirildiğinde, rijit bir bağlantıyla deliğe oturur.", + "help.parts.library": "Standart parçaları açar: standart boyutlarda vidalar, cıvatalar, somunlar, rondelalar, tespit vidaları, silindirik pimler ve rulmanlar. Bir standart parçayı kendiniz modellemek yerine eklemek için kullanın.", + "help.parts.resize": "Eklenmiş bir standart parçanın boyutunu veya uzunluğunu değiştirir ve yeniden oluşturur; bağlantıları onu yerinde tutar. Bir vida çok uzun çıkarsa veya bir delik başka bir boyut gerektiriyorsa kullanın.", + "help.plastic.assign_rule": "Gövdelere bir plastik kuralı atar; böylece plastik özellikleri koniklik ve açıklık değerlerini varsayılan olarak ondan alır. Kalıplanmış bir parçayı tüm özellikleri için bir kez kurmak amacıyla kullanın.", + "help.plastic.boss": "Bir yüze vida göbeği ekler: isteğe bağlı delik, koniklik, kök yuvarlatması ve destek nervürleri olan bir direk. Kalıplanmış veya basılmış kasalardaki vida noktaları için kullanın.", + "help.plastic.lip": "İçi boşaltılmış bir gövdenin kenarı boyunca bir dudak veya buna uyan bir oluk ekler. İki kasa yarısının birbirine oturması ve sıkıca kapanması için kullanın.", + "help.plastic.manage_rules": "Plastik kurallarını oluşturur ve düzenler: malzeme, et kalınlığı, yarıçaplar, açıklık ve koniklik. Kalıplanmış bir parçanın özelliklerini tutarlı tutmak için kullanın.", + "help.plastic.rest": "Bir yüze dayanak ekler: yuvarlak veya dikdörtgen, dolu veya içi boş, konikliği olan bir ayak. Kalıplanmış bir kasada destek veya mesafe parçası olarak kullanın.", + "help.plastic.snap_fit": "Bir yüze, uzunluğu, kalınlığı ve tırnağıyla bir geçmeli bağlantı kancası ekler. Parçaları vidasız birbirine geçirmek için kullanın.", "help.pointcloud.insert": "PLY, XYZ veya PTS taramasını modelin yanına nokta bulutu olarak ekler. Ölçülmüş verilerden model oluştururken referans olarak kullanın.", + "help.sheet.convert": "Eşit kalınlıkta bir plakayı veya içi boşaltılmış bir gövdeyi, seçtiğiniz kenarlarda bükümlerle sac metal gövdeye dönüştürür. Mevcut bir parçayı açılabilir hâle getirmek için kullanın.", + "help.sheet.export_bend_table": "Bir sac metal gövdenin büküm tablosunu CSV veya JSON olarak kaydeder: açılar, yarıçaplar ve açınımdaki büküm konumları. Abkant pres veya atölye için kullanın.", + "help.sheet.export_dxf": "Açınımı DXF olarak kaydeder; dış hatlar, büküm çizgileri ve isteğe bağlı büküm notlarıyla. Lazer, plazma veya su jeti ile kesim için kullanın.", + "help.sheet.flange": "Bir flanş ekler: bir eskizden taban plakası, açık bir eskiz konturu boyunca bir cidar ya da sac kenarlarında bükülmüş bir cidar. Sac metal parçaları oluşturmak için kullanın.", + "help.sheet.flat_pattern": "Bir sac metal gövdenin açınımını boyutu, kesik alanları ve büküm çizgileriyle oluşturur. Kesmeden önce ham parçayı kontrol etmek için kullanın.", + "help.sheet.fold": "Bir sacı, bir eskiz çizgisi boyunca belirli bir açı ve yarıçapla büker. İçine kesme özellikleri ekledikten sonra düz bir sacı katlamak için kullanın.", + "help.sheet.hem": "Bir sac kenarını kendi üzerine geri katlar. Keskin çapaklar içermeyen, güvenli ve sağlam kenarlar için kullanın.", + "help.sheet.manage_rules": "Sac metal kurallarını oluşturur ve düzenler: kalınlık, büküm yarıçapı, K faktörü, boşaltmalar ve boşluklar. Atölyenizin malzemesine ve takımlarına uyum sağlamak için kullanın.", + "help.sheet.path_flange": "Açık bir eskiz çizgisi zincirini izleyerek bir sac kenarı boyunca bir flanş zinciri ekler. U veya Z biçimi gibi profilleri tek adımda oluşturmak için kullanın.", + "help.sheet.perforate": "Tüm düz sac üzerine, kaydırmalı veya kare desende bir delik alanı ekler. Havalandırmalar, ızgaralar ve daha hafif paneller için kullanın.", + "help.sheet.refold": "Açılmış bir sac metal gövdeyi yeniden katlar. Düz sace özellikler ekledikten sonra kullanın.", + "help.sheet.unfold": "Bir sac metal gövdenin tüm bükümlerini açar. Bir bükümü kesmesi gereken delikler veya kesimler eklemek için kullanın, ardından yeniden katlayın.", + "help.sketch.arc.center_point": "Bir merkez noktası etrafında, bir başlangıç noktasından bir bitiş noktasına dairesel bir yay çizer. Yayın merkezi biliniyorsa kullanın; örneğin bir deliğin ya da bir dönme noktasının etrafındaki yarıçap.", + "help.sketch.arc.tangent": "Bir çizginin veya yayın ucunda başlayan ve ona teğet olarak devam eden bir yay çizer. Zinciri köşe yerine yumuşak bir dönüşle sürdürmek için kullanın.", + "help.sketch.arc.three_point": "Bir başlangıç noktasından bir bitiş noktasına, üçüncü bir noktadan geçen bir yay çizer. Yay, bilinen bir merkez etrafında değil de verilen noktalardan geçmek zorundaysa kullanın.", + "help.sketch.arc3d": "3B eskiz açıkken, uzayda üç noktadan geçen tam bir dairesel yay çizer. Eskiz düzleminden çıkan yolların ve kılavuz raylarının kıvrımları için kullanın.", + "help.sketch.auto_constrain": "Eskizin neredeyse zaten sahip olduğu kısıtlamaları, örneğin yatay, paralel veya çakışık gibi olanları ekler, ardından tam tanımlanana kadar ölçülendirir. Kabaca çizilmiş bir eskizi tek adımda kilitlemek için kullanın.", + "help.sketch.auto_constrain_from_datum": "Otomatik kısıtlama gibi çalışır, ancak konumları orijin yerine seçtiğiniz bir referans noktasından ölçer. Parça tek bir referans noktasından, örneğin bir köşeden veya bir delikten yerleştirilmişse kullanın.", + "help.sketch.blend_curve": "İki eğrinin uçlarını, ikisine de teğet, istenirse eğrilik sürekli de olan pürüzsüz bir spline ile birleştirir. Eğriler arasındaki boşluğu kırıksız kapatmak için kullanın.", + "help.sketch.break": "Bir eğriyi, başka eğrilerin onu kestiği yerlerde veya üzerine koyduğunuz noktalarda parçalara böler. Eğrinin bir bölümü kendi kısıtlamasına ihtiyaç duyuyorsa ya da tek başına silinecekse kullanın.", + "help.sketch.cancel": "Eskizden çıkar ve oluşturulduğundan ya da düzenleme için açıldığından beri yapılan her değişikliği atar. Geri al değişiklikleri geri getirir.", + "help.sketch.centerline": "Çizgileri normal çizgi ile merkez çizgisi, yani profillerin dışında bıraktığı bir eksen, arasında değiştirir. Merkez çizgisini döndürme, ayna veya simetri kısıtlaması için eksen olarak kullanın.", + "help.sketch.chamfer.distance_angle": "İki çizgi arasındaki köşeyi, ilk çizgi boyunca bir mesafe ve bir açıyla belirlenen şekilde pahlar. Bir çizim pahı uzunluk ve açı olarak veriyorsa, örneğin 2 mm × 30°, kullanın.", + "help.sketch.chamfer.equal_distance": "İki çizgi arasındaki köşeyi, her iki çizgi boyunca aynı mesafe geriden pahlar. Bir dış hatta uygulanan olağan 45° pah için kullanın.", + "help.sketch.chamfer.two_distance": "İki çizgi arasındaki köşeyi, her çizgi boyunca ayrı bir mesafeyle belirlenen şekilde pahlar. İki uzunluk olarak verilen, 45° olmayan pahlar için kullanın.", + "help.sketch.circle.center": "Bir daireyi merkezinden ve çapından çizer. Merkezi bilinen delikler, kabarıklıklar ve diğer yuvarlak özellikler için kullanın.", + "help.sketch.circle.three_point": "Üç noktadan geçen bir daire çizer. Dairenin verilen noktalardan, örneğin mevcut geometrinin noktalarından geçmesi gerekiyorsa kullanın.", + "help.sketch.circle.three_tangent": "Üç çizgiye, daireye veya yaya değen bir daire çizer. Bir daireyi üç eğri arasındaki boşluğa oturtmak için kullanın; örneğin bir üçgenin iç teğet çemberi.", + "help.sketch.circle.two_point": "Bir daireyi çapının iki ucundan çizer. Merkezi değil de dairenin kenar noktaları biliniyorsa kullanın.", + "help.sketch.circle.two_tangent": "İki çizgiye, daireye veya yaya değen, verilen boyutta bir daire çizer. İki eğri arasında sabit yarıçaplı bir yuvarlatma için kullanın.", + "help.sketch.conic": "İki uç nokta arasında, bir tepe noktası ve bir rho değeriyle biçimlenen konik bir eğri çizer. Doluluğunu düz bir hattan neredeyse keskin bir köşeye kadar ayarlamak istediğiniz yumuşak geçişler için kullanın.", + "help.sketch.constrainer": "Seçilen bir veya iki eğrinin neredeyse zaten sahip olduğu kısıtlamayı, örneğin yatay, paralel, teğet veya eşit gibi olanı uygular. Elle çizilmiş bir eskizi her seferinde bir ilişkiyle düzene sokmak için kullanın.", + "help.sketch.constraint.coincident": "Bir noktayı başka bir noktanın, bir eğrinin veya bir eskiz ekseninin üzerine oturtur. Bir profildeki boşlukları kapatmak veya bir noktayı çizgi üzerine koymak için kullanın.", + "help.sketch.constraint.collinear": "İki çizgiyi aynı doğru üzerine getirir. Kesintiye uğramış kenarları, örneğin bir çentiğin iki yanını hizalamak için kullanın.", + "help.sketch.constraint.concentric": "İki daireye veya yaya aynı merkezi verir. Halkalar, düz havşalar ve omuzlu miller için kullanın.", + "help.sketch.constraint.curvature": "Bir uç noktada buluşan iki eğrinin aynı eğrilikle devam etmesini sağlar (G2). Teğet bir birleşimin yeterince pürüzsüz olmadığı yerlerde, örneğin görünen yüzeylerde kullanın.", + "help.sketch.constraint.equal": "Çizgileri aynı uzunlukta, daireleri ve yayları aynı yarıçapta yapar. Tek bir ölçünün birbirine uyan birkaç özelliği yönetmesi için kullanın.", + "help.sketch.constraint.fix": "Noktaları veya eğrileri bulundukları yere sabitler ya da yeniden serbest bırakır. Eskizi sürüklediğinizde veya çözdüğünüzde hareket etmemesi gereken geometri için kullanın.", + "help.sketch.constraint.horizontal_vertical": "Bir çizgiyi ya da iki nokta arasındaki çizgiyi, hangisine daha yakınsa yatay veya dikey yapar. Bir dış hattı eskiz eksenlerine hizalamak için kullanın.", + "help.sketch.constraint.midpoint": "Bir noktayı bir çizginin ortasına yerleştirir ya da iki çizginin orta noktalarını çakıştırır. Geometriyi bir kenar üzerinde ortalamak için kullanın.", + "help.sketch.constraint.parallel": "İki çizgiyi paralel yapar. Diğer ölçüler değiştiğinde paralel kalması gereken kenarlar için kullanın.", + "help.sketch.constraint.perpendicular": "İki çizgiyi dik açıyla buluşturur. Eskiz eksenleri üzerinde olmayan çizgiler arasındaki dik köşeler için kullanın.", + "help.sketch.constraint.polygon": "Kapalı bir çizgi zincirini düzgün bir çokgen yapar: eşit kenarlar ve her köşe tek bir daire üzerinde. Elle çizilmiş bir çokgeni düzgün hâle getirmek ve öyle tutmak için kullanın.", + "help.sketch.constraint.symmetry": "İki noktanın veya eğrinin bir çizgiye göre ya da bir noktaya göre birbirinin aynası olmasını sağlar. Bir tarafı değiştirdiğinizde profilin simetrik kalması için kullanın.", + "help.sketch.constraint.tangent": "İki eğriyi, örneğin bir çizgiyi ve bir yayı, teğet olarak değdirir. Kırıksız, pürüzsüz birleşimler için kullanın.", + "help.sketch.construction": "Eğrileri normal geometri ile profillerin dışında bıraktığı yapı geometrisi arasında değiştirir. Yapı eğrilerini yerleşim, referans ve ölçüler için yardımcı olarak kullanın.", "help.sketch.construction_line": "İki noktadan geçen sonsuz bir yardımcı doğru oluşturun. Profilleri bölmeden bir eskizi kurgulamak için kullanın.", "help.sketch.construction_line.angle": "Eskizin X ekseninden belirli bir açıyla sonsuz bir yardımcı doğru oluşturun. Noktayı seçip açıyı yazın veya yönünü seçin.", "help.sketch.construction_line.bisector": "Kesişen iki düz eskiz çizgisini seçin, ardından iki açıortaydan birini seçin. Sonuç, kaynak çizgilerini izler.", @@ -1412,11 +1564,71 @@ "help.sketch.construction_line.tangent_external": "Bir daire veya dairesel yay seçin, ardından bir dış nokta ve bir teğet çözümü seçin. Önizlemeler yalnızca çizilen yay üzerindeki değme noktalarını gösterir.", "help.sketch.construction_line.tangent_two": "İki daire veya dairesel yay seçin, ardından önizlemede ortak bir teğet seçin. Değme noktaları çizilen eğriler üzerinde olmalıdır.", "help.sketch.construction_line.vertical": "Bir noktadan geçen sonsuz dikey bir yardımcı doğru oluşturun. Dikey kısıtlaması sonraki düzenlemelerde de korunur.", + "help.sketch.copy": "Seçili eskiz eğrilerini ve noktalarını, aralarındaki kısıtlamalar ve ölçülerle birlikte kopyalar. Bunları bu eskize veya başka bir eskize yapıştırın.", + "help.sketch.create": "Bir düzlemde veya modelin düz bir yüzünde yeni bir 2B eskiz başlatır. Her ekstrüzyon, döndürme ve süpürme bir eskizle başlar.", + "help.sketch.cut": "Seçili eskiz geometrisini kopyalar ve tek bir geri alma adımında eskizden siler. Bu eskizin başka bir yerine veya başka bir eskize yapıştırın.", + "help.sketch.dimension": "Sürücü bir ölçü ekler: seçtiğinize göre bir uzunluk, mesafe, açı, yarıçap veya çap. Bir değer ya da parametre adı yazın, eskiz onu izler.", + "help.sketch.edit": "Mevcut bir eskizi düzenlemek için açar. Üzerine kurulan özellikler, eskizi bitirdiğinizde güncellenir.", + "help.sketch.ellipse": "Bir elipsi merkezinden, büyük ekseninin ucundan ve küçük kenarındaki bir noktadan çizer. Oval açıklıklar, kamlar ve diğer uzamış yuvarlak biçimler için kullanın.", "help.sketch.equation_curve": "Formüllerden bir eğri çizer: bir aralık boyunca x(t) ve y(t) ya da y = f(x). Formüller parametre kullanabilir ve eğri onları izler: sinüs dalgası, spiral veya bir dişli dişinin evolventi için kullanın.", + "help.sketch.extend": "Bir çizgiyi veya yayı yolundaki en yakın eğriye kadar uzatır ya da iki eğriyi buluşana kadar büyütür. Kırpma veya düzenlemeden sonra bir profildeki boşlukları kapatmak için kullanın.", + "help.sketch.fillet": "İki çizginin veya yayın buluştuğu köşeyi verilen yarıçaplı bir yayla yuvarlar. Birlikte yuvarlatılan birkaç köşe aynı yarıçapı paylaşır.", + "help.sketch.finish": "Eskizden çıkar ve modele döner. Kapalı profilleri artık Ekstrüzyon, Döndür ve diğer özellikler için hazırdır.", + "help.sketch.finish_auto_constrain": "Eskize Otomatik kısıtlama uygular, ardından eskizi bitirir. Bir eskizi tek adımda tam tanımlı bırakmak için kullanın.", + "help.sketch.fit_curves_to_section": "Bir ağ veya katı gövdeyi eskiz düzlemiyle keser ve kesite çizgiler ile yaylar uydurur. Taranmış veya içe aktarılmış bir parçayı düzenlenebilir bir eskiz olarak yeniden kurmak için kullanın.", + "help.sketch.include_3d": "Model kenarlarını, köşe noktalarını ve diğer eskizlerin eğrilerini bu eskize bağlı referanslar olarak getirir; düzleminin dışında kalanları ona düzleştirir. Eskiz düzleminde bulunmayan geometri üzerine kurmak için kullanın.", + "help.sketch.infinite": "Çizgileri normal çizgi ile tüm görünüm boyunca uzanan sonsuz yardımcı doğrular arasında değiştirir. Hiçbir zaman kısa kalmayan yerleşim referansları olarak kullanın.", + "help.sketch.insert_dxf": "Bir DXF çiziminin eğrilerini, belirlediğiniz ölçekte, açıda ve konumda bir eskize okur. Başka bir programdan gelen 2B çizim üzerine, örneğin lazerle kesilecek bir dış hat üzerine kurmak için kullanın.", + "help.sketch.insert_svg": "Bir SVG çiziminin yollarını, belirlediğiniz ölçekte, açıda ve konumda bir eskize okur. Bir logoyu veya vektör dış hattını ekstrüzyon yapabileceğiniz eğrilere dönüştürmek için kullanın.", + "help.sketch.inspect": "Açık eskiz için bir rapor açar: kaç serbestlik derecesi kaldığını ve tam kısıtlı olup olmadığını. Eskizi bitirmeden önce nelerin hâlâ hareket ettiğini öğrenmek için kullanın.", + "help.sketch.intersect": "Gövdelerin, yüzlerin, kenarların veya diğer eskizlerin eskiz düzlemini kestiği eğrileri ya da noktaları ekler. Bağlı kalır ve model değiştiğinde onu izler.", + "help.sketch.intersection_curve": "İki gövdenin veya yüzün buluştuğu yerde, onlara bağlı 3B eğriler oluşturur. Bunları modeli izleyen yollar veya kılavuz raylar olarak kullanın.", + "help.sketch.isoparametric_curve": "Bir yüzün U veya V yönü boyunca, seçtiğiniz bir noktadan geçen ve yüze bağlı bir 3B eğri oluşturur. Yüzeyi izleyen bir yol veya bölme çizgisi olarak kullanın.", + "help.sketch.line": "Bağlantılı çizgileri nokta nokta, açık bir zincir veya kapalı bir dış hat olarak çizer. Çoğu profil için temel araçtır.", + "help.sketch.line.midpoint": "Bir çizgiyi orta noktasından bir uca doğru çizer; diğer uç yansıtılır. Bir nokta etrafında ortalanmış çizgiler, örneğin simetrik bir kenar için kullanın.", + "help.sketch.line3d": "3B eskiz açıkken, uzaydaki noktalar arasında bağlantılı düz çizgiler çizer. Eskiz düzleminden çıkan yollar ve çerçeveler için kullanın.", + "help.sketch.mirror": "Eskiz eğrilerini ve noktalarını bir çizgi veya eksen boyunca aynalar ve simetri kısıtlamaları ekler. Simetrik bir profilin yarısını çizip tamamlamak için kullanın.", + "help.sketch.move": "Eskiz eğrilerini ve noktalarını ya da kopyalarını taşır veya döndürür. Geometrinin şeklini ve kısıtlamalarını koruyarak yerini değiştirmek için kullanın.", + "help.sketch.offset": "Bir eğri zincirinin kopyasını, bir tarafta ya da her iki tarafta belirli bir mesafede oluşturur. Cidarlar, dış hatlar ve açıklıklar için kullanın; mesafe düzenlenebilir bir ölçü olarak kalır.", + "help.sketch.paste": "Kopyaladığınız veya kestiğiniz eskiz geometrisini etkin eskize yapıştırır. Bir noktaya yerleştirin ya da bir ofsetle kaydırın.", + "help.sketch.pattern.circular": "Eskiz geometrisini bir merkez noktası etrafında, tam bir daire veya belirli bir açı boyunca kopyalar. Cıvata daireleri, kollar ve dişler için kullanın.", + "help.sketch.pattern.rectangular": "Eskiz geometrisini belirli aralıklarla satırlar ve sütunlar halinde kopyalar. Delik, yuva veya havalandırma ızgaraları için kullanın.", + "help.sketch.point": "Bir eskiz noktası yerleştirir. Ölçüler, kısıtlamalar, delik merkezleri veya sonraki özellikler için referans olarak kullanın.", + "help.sketch.point3d": "3B eskiz açıkken uzayda bir nokta yerleştirir. 3B eğriler ve yollar için referans olarak kullanın.", + "help.sketch.polygon.circumscribed": "Bir dairenin etrafına, merkezinden bir kenarın ortasına kadar olan mesafeye göre boyutlandırılmış düzgün bir çokgen çizer. Anahtar ağzı genişliği biliniyorsa, örneğin altıgen bir somun için kullanın.", + "help.sketch.polygon.edge": "Düzgün bir çokgeni bir kenarının iki ucundan çizer. Bir kenarın uzunluğu ve konumu biliniyorsa kullanın.", + "help.sketch.polygon.inscribed": "Bir dairenin içine, merkezinden bir köşeye kadar olan mesafeye göre boyutlandırılmış düzgün bir çokgen çizer. Köşeler arası ölçü biliniyorsa kullanın.", + "help.sketch.project": "Model kenarlarını, yüzlerini, köşe noktalarını ve diğer eskizlerin eğrilerini bu eskizin düzlemine yansıtarak getirir. Bağlı kalır ve model değiştiğinde onu izler.", + "help.sketch.project_to_surface": "Diğer eskizlerin eğrilerini bu eskizin normali boyunca bir yüze, bağlı 3B eğriler olarak yansıtır. Bir logoyu, bölme çizgisini veya yolu eğri bir yüzeye koymak için kullanın.", + "help.sketch.rectangle.center": "Bir dikdörtgeni merkezinden ve bir köşesinden çizer. Bir nokta, örneğin orijin, etrafında ortalanmış dış hatlar için kullanın.", + "help.sketch.rectangle.rounded": "Dört köşesi de tek bir yarıçapla yuvarlatılmış bir dikdörtgen çizer. Yuvarlatılmış köşeli plakalar, kapaklar ve açıklıklar için kullanın.", + "help.sketch.rectangle.three_point": "Bir dikdörtgeni bir kenarından ve karşı taraftaki bir noktadan çizer. Eskiz eksenlerine açılı duran dikdörtgenler için kullanın.", + "help.sketch.rectangle.two_point": "Bir dikdörtgeni iki karşılıklı köşesinden çizer. Plakaların, blokların ve ceplerin dış hatları için kullanın.", + "help.sketch.scale": "Eskiz eğrilerini ve noktalarını bir taban noktası etrafında bir çarpanla ölçekler. Bir eskizin bir bölümünü yeniden çizmeden boyutlandırmak için kullanın.", + "help.sketch.slot.arc_center": "Merkezi, başlangıcı ve bitişiyle belirlenen bir yay boyunca, verilen genişlikte eğri bir yuva çizer. Bir dönme noktası etrafındaki ayar yuvaları için kullanın.", + "help.sketch.slot.arc_three_point": "Üç noktadan geçen bir yay boyunca, verilen genişlikte eğri bir yuva çizer. Yuvanın yolunun verilen noktalardan geçmesi gerekiyorsa kullanın.", + "help.sketch.slot.center_point": "Verilen genişlikte düz bir yuvayı, merkezinden bir uç yayının merkezine kadar çizer. Bir nokta etrafında ortalanmış yuvalar için kullanın.", + "help.sketch.slot.center_to_center": "Verilen genişlikte düz bir yuvayı, iki uç yayının merkezleri arasında çizer. Merkezden merkeze ölçülendirilen yuvalar, örneğin vida ayar yuvaları için kullanın.", + "help.sketch.slot.overall": "Verilen genişlikte düz bir yuvayı, bir dış ucundan diğerine kadar çizer. Yuvanın toplam uzunluğu önemliyse kullanın.", + "help.sketch.spline.control_point": "Üzerinden geçmediği kontrol noktalarıyla biçimlenen kübik bir spline çizer. Noktaları oynatarak şekillendirdiğiniz pürüzsüz, akıcı eğriler için kullanın.", + "help.sketch.spline.control_point_5": "Kontrol noktalarıyla biçimlenen 5. dereceden bir spline çizer. Eğrilerin daha pürüzsüz bir eğrilikle birleşmesi gereken yerlerde, örneğin görünen stil yüzeylerinde kullanın.", + "help.sketch.spline.fit_point": "Yerleştirdiğiniz her noktadan geçen, açık veya kapalı pürüzsüz bir spline çizer. Bir şekli bilinen noktalardan geçirerek izlemek için kullanın.", + "help.sketch.spline3d": "3B eskiz açıkken, uzayda noktalardan geçen pürüzsüz bir eğri çizer. Eskiz düzleminden çıkan süpürme yolları ve kablo güzergâhları için kullanın.", + "help.sketch.spun_profile": "Bir gövde bir eksen etrafında dönerken taradığı dış hattı eskiz düzleminde oluşturur. Tornalanmış bir parçanın siluetini döndürme veya çizim için elde etmek amacıyla kullanın.", + "help.sketch.text": "Eskize seçilen yazı tipi, yükseklik ve açıyla metin yazar; kelimeleri parametreleri izleyebilir. Etiketler ve yazılar için ekstrüzyon yapın veya kabartın.", + "help.sketch.transform": "Eskiz geometrisini bir merkez noktası etrafında tek adımda ölçekler, döndürür ve taşır. Bir seçimin yerini ve boyutunu aynı anda değiştirmek için kullanın.", + "help.sketch.trim": "Bir eğrinin, tıkladığınız yerin çevresindeki en yakın kesişimler arasında kalan parçasını kaldırır. Üst üste binen çizgileri temizleyip kapalı bir profil elde etmek için kullanın.", "help.solid.align": "Seçili gövdeleri, üzerlerindeki bir nokta, yüz veya düzlem başka bir yerdeki nokta, yüz veya düzlemle buluşacak şekilde taşır. Ofseti ölçmeden bir parçayı diğerinin yanına yerleştirmek için kullanın.", + "help.solid.boundary_fill": "Üst üste binen araç gövdelerinin çevrelediği bölgelerden katılar oluşturur; yalnızca seçtiğiniz bölgeleri doldurur. Bir şekli, birkaç gövdenin birlikte sınırladığı boşluktan oluşturmak için kullanın.", + "help.solid.bounding_solid": "Seçili gövdeleri çevreleyen, isteğe bağlı paylı bir kutu oluşturur. Bir parçanın toplam boyutunu görmek ya da işleneceği ham malzeme olarak ele almak için kullanın.", + "help.solid.box": "Uzunluk, genişlik ve yükseklikten, bir köşe noktasından veya bir noktaya ortalanmış bir kutu oluşturur. Hızlı bir blok, yer tutucu ya da içine özellikler kesilecek bir gövde için kullanın.", "help.solid.chamfer": "Seçili kenarları, belirlediğiniz mesafede düz bir pahla keser. Keskin kenarları kırmak veya bir pim ya da vidanın girişini kolaylaştırmak için kullanın.", + "help.solid.coil": "Çap, tur sayısı, yükseklik veya adım ile yuvarlak ya da kare bir kesitten, yay gibi helezon bir gövde oluşturur. Yaylar, helezonlar ve helezon kabartmalar için kullanın.", "help.solid.combine": "Gövdeleri birleştirir, bir hedef gövdeden çıkarır veya yalnızca paylaştıkları hacmi tutar ve araç gövdelerini tutabilir. Parçaları kaynaştırmak, bir şekli diğeriyle kesmek veya iki gövdenin örtüştüğü yeri bulmak için kullanın.", + "help.solid.cone": "Bir taban dairesi ve bir yükseklikten koni oluşturur; üstün de yarıçapı varsa kesik koni olur. Konik geçişler, huniler, havşalar ve sivri uçlar için kullanın.", + "help.solid.cylinder": "Yarıçap veya çap ile yükseklikten, bir eksen boyunca bir noktanın üzerinde duran bir silindir oluşturur. Miller, pimler, göbekler ve yuvarlak ham parçalar için kullanın.", "help.solid.draft": "Seçili yüzleri, nötr bir düzlemden ölçülen belirlediğiniz açı kadar eğerek koniklik verir. Kalıplanan veya dökülen parçaların kalıptan kolayca çıkması için kullanın.", + "help.solid.emboss": "Metin veya logo gibi eskiz profillerini bir yüzden yukarı kaldırır ya da içine gömer. Düz veya yuvarlak yüzlerdeki yazılar, etiketler ve işaretler için kullanın.", "help.solid.extrude": "Bir eskiz profilini, malzeme ekleyerek veya kaldırarak bir mesafe boyunca 3B'ye taşır. Düz bir konturu blok, kabarıklık veya cep haline getirmek için kullanın.", "help.solid.fillet": "Seçili kenarları, belirlediğiniz yarıçapta pürüzsüz dairesel bir uyumla yuvarlatır. Keskin kenarları sağlamlık, konfor veya görünüm için yumuşatmak amacıyla kullanın.", "help.solid.hole": "Bir yüzeyde seçtiğiniz noktada, belirlediğiniz çapta yuvarlak bir delik deler. Vida boşlukları, pimleri ve diğer basit yuvarlak açıklıklar için kullanın.", @@ -1425,20 +1637,60 @@ "help.solid.move": "Seçili gövdeleri ya da kopyalarını, belirlediğiniz mesafe ve açıyla taşır veya döndürür. Bir gövdenin yerini değiştirmek ya da başka bir yerde ikincisini oluşturmak için kullanın.", "help.solid.offset_face": "Seçili yüzleri belirlediğiniz mesafe kadar içeri veya dışarı iter, çevrelerindeki yüzler de buna uyar. Eskizi düzenlemeden bir duvarı kalınlaştırmak, bir cebi derinleştirmek veya bir çıkıntıyı yükseltmek için kullanın.", "help.solid.pattern.circular": "Seçili nesneleri, belirlediğiniz adet ve toplam açı boyunca bir eksen etrafında yineler. Cıvata daireleri, jant kolları, kanatçıklar ve dişler için kullanın.", + "help.solid.pattern.path": "Özellikleri veya gövdeleri bir eskiz yolu boyunca adet ve aralıkla kopyalar. Eğri bir kenar boyunca delikler veya bir ray boyunca parçalar için kullanın.", "help.solid.pattern.rectangular": "Seçili nesneleri, belirlediğiniz adet ve aralıkla bir veya iki yönde yineler. Delik, nervür, pim veya ayak dizileri ve ızgaraları için kullanın.", + "help.solid.pipe": "Eskiz eğrilerinden oluşan bir yol boyunca, dolu veya et kalınlıklı içi boş yuvarlak bir boru oluşturur. Borular, teller, kablolar ve raylar için kullanın.", "help.solid.press_pull": "Bir yüzü sürüklerken malzeme ekler veya kaldırır, kenarları sürüklerken yuvarlatır. Doğrudan model üzerinde hızlı değişiklikler için kullanın.", "help.solid.remove": "Seçili gövdeleri tasarımdan, zaman çizelgesindeki bu noktadan itibaren çıkarır; önceki adımlar onları korur. Artık gerekmeyen bir kesici araç gibi yardımcı bir gövdeyi atmak için kullanın.", "help.solid.replace_face": "Seçili düz yüzleri bir düzleme veya paralel bir yüze taşır, gövde de onunla buluşmak için büyür veya küçülür. Bir yüzü başka bir parçayla ya da bir yapı düzlemiyle aynı hizaya getirmek için kullanın.", "help.solid.revolve": "Bir eskiz profilini bir eksen etrafında döndürerek yuvarlak bir katı oluşturur. Miller, topuzlar, kaseler ve bir merkez çizgisi etrafında yuvarlak olan parçalar için kullanın.", + "help.solid.rib": "Açık bir eskiz eğrisi boyunca, gövdenin bir sonraki yüzlerine kadar uzanan ince bir cidar ekler. Cidarları ve köşeleri sağlamlaştırmak için, örneğin kalıplanmış veya basılmış parçalarda kullanın.", "help.solid.scale": "Seçili gövdeleri bir çarpanla büyütür veya küçültür; her yönde aynı ya da eksen başına ayrı olabilir. İçe aktarılan bir şekli yeniden boyutlandırmak veya büzülme payı bırakmak için kullanın.", "help.solid.shell": "Bir katıyı içini oyarak boşaltır, belirlediğiniz kalınlıkta duvarlar bırakır ve seçtiğiniz yüzleri açabilir. Kutular, kapaklar ve diğer ince cidarlı parçalar için kullanın.", + "help.solid.sphere": "Bir merkez noktası etrafında, yarıçap veya çaptan bir küre oluşturur. Toplar, topuzlar ve yuvarlatılmış uçlar için kullanın.", "help.solid.split_body": "Bir gövdeyi bir düzlemle, başka bir gövdenin yüzüyle veya bir eskiz çizgisiyle ayrı gövdelere keser. Ayrı ayrı basılan, işlenen veya renklendirilen parçalar elde etmek için kullanın.", "help.solid.split_face": "Seçili yüzleri, gövdeyi kesmeden bir düzlem, başka bir gövdenin yüzü veya eskiz eğrileri boyunca böler. Bir yüzün bir kısmına kendi koniklik, renk veya ofsetini vermek için kullanın.", "help.solid.sweep": "Bir eskiz profilini bir yol boyunca sürükleyerek katı oluşturur. Borular, raylar, saplar ve bir eğriyi izleyen sabit kesitli parçalar için kullanın.", + "help.solid.thread": "Silindirik bir yüze, kozmetik diş olarak ya da gövdeye kesilmiş olarak bir ISO metrik diş ekler. Cıvatalar, dişli delikler ve basılmış vida parçaları için kullanın.", + "help.solid.torus": "Ekseni etrafındaki bir büyük yarıçap ve yuvarlak kesiti için bir küçük yarıçaptan halka oluşturur. O-ringler, saplar ve yuvarlak boncuklar için kullanın.", + "help.solid.web": "Her açık eskiz eğrisi boyunca, her biri gövdenin bir sonraki yüzlerine kadar uzanan ince bir cidar ekler. Kasaların ve tepsilerin içindeki destek cidarlarından oluşan bir ızgara için kullanın.", + "help.surface.blend": "İki gövdenin kenarları arasında köprü kuran, ikisine de teğet veya eğrilik sürekli bir yüzey oluşturur. İki yüzeyi bir boşluk boyunca pürüzsüzce birleştirmek için kullanın.", + "help.surface.boundary": "İki ila dört eskiz eğrisinden oluşan bir döngüyü veya bir eğri ağını dolduran bir yüzey oluşturur. Kenarlarıyla tanımlanan serbest biçimli paneller için kullanın.", + "help.surface.extend": "Bir yüzeyi kenarlarının ötesine, bir mesafe kadar ya da bir düzleme veya yüze kadar uzatır. Yüzeyleri kırpmadan veya dikmeden önce üst üste bindirmek için kullanın.", + "help.surface.extrude": "Eskiz eğrilerini veya profillerini bir mesafe boyunca iterek kapaksız, yalnızca cidarlardan oluşan bir yüzey elde eder. İnce cidarlar ile kesme veya bölme araçları için kullanın.", + "help.surface.fair": "Serbest biçimli yüzlerin içini düzgünleştirir; kenarları yerinde kalır. Bir yüzeydeki çıkıntıları ve dalgalı yansımaları gidermek için kullanın.", + "help.surface.loft": "İki veya daha fazla kesitten sırayla geçen, kapaksız bir yüzey oluşturur. Kabuklar ve farklı şekiller arasındaki geçişler için kullanın.", + "help.surface.match": "Bir yüzeyin kenarını, başka bir gövdenin kenarıyla teğet veya eğrilik uyumuyla buluşacak şekilde yeniden kurar. İki yüzey arasındaki bir birleşim yerini pürüzsüzce kapatmak için kullanın.", + "help.surface.offset": "Yüzlerin kopyasını, normalleri boyunca bir mesafede yeni bir yüzey gövdesi olarak oluşturur. Kaplamalar, açıklıklar veya bir cidarın öbür yüzü için kullanın.", + "help.surface.patch": "Bir eskiz bölgesini veya kapalı bir kenar döngüsünü, düz ya da pürüzsüzce eğri bir yüzle doldurur. Bir yüzey gövdesindeki delikleri kapatmak için kullanın.", + "help.surface.reverse_normal": "Bir yüzey gövdesinin yüzlerini ters çevirir; böylece dış tarafları diğer yöne bakar. Yüzler içe bakıyorsa kalınlaştırmadan veya dikmeden önce kullanın.", + "help.surface.revolve": "Eskiz eğrilerini veya profillerini bir eksen etrafında döndürerek kapaksız bir yüzey elde eder. Abajurlar, kaseler ve nozullar gibi yuvarlak kabuklar için kullanın.", + "help.surface.ruled": "Bir kenar zincirinden, yüzün normali veya teğeti boyunca ya da bir açıyla çıkan düz çizgilerden oluşan bir şerit oluşturur. Koniklikli cidarlar, taşma yüzeyleri ve ayrım yüzeyleri için kullanın.", + "help.surface.stitch": "Yüzey gövdelerini ortak kenarları boyunca birbirine diker; kapandıklarında katı olur. Bir yüzey kümesini tek bir gövdeye dönüştürmek için kullanın.", + "help.surface.sweep": "Eskiz eğrilerini veya profillerini bir yol boyunca hareket ettirerek kapaksız bir yüzey oluşturur. Oluklar, çıtalar ve bir yolu izleyen diğer ince şekiller için kullanın.", + "help.surface.thicken": "Bir yüzey gövdesine kalınlık vererek onu katıya dönüştürür. Yüzeylerden basılabilir veya işlenebilir parçalar elde etmek için kullanın.", + "help.surface.trim": "Bir yüzeyi bir düzlemle, başka bir yüzeyle veya eskiz eğrileriyle keser ve seçtiğiniz tarafı tutar. Yüzeyleri dikmeden önce şekle kesmek için kullanın.", + "help.surface.unstitch": "Bir gövdenin yüzlerini kendi yüzey gövdelerine ayırır; bir katı yüzeylere dağılır. Bir gövdenin tek tek yüzlerini yeniden işlemek için kullanın.", + "help.surface.untrim": "Bir yüzdeki delikleri kaldırır ya da dış sınırını yüzeyin doğal sınırlarına geri getirir. Kırpmadan sonra yüzeyin tamamını geri almak için kullanın.", "help.third_party": "Üçüncü taraf lisansları", "help.timeline.compute_all": "Zaman çizelgesindeki her özelliği baştan yeniden oluşturur. Modeli tazelemek ve hangi özelliklerin başarısız olduğunu görmek için kullanın.", "help.timeline.delete": "Seçili özellikleri zaman çizelgesinden siler; bir eskizi silmek ondan oluşturulan özellikleri de siler. Artık istenmeyen bir adımı kaldırmak için kullanın.", + "help.timeline.dependents": "Bir özellikle birlikte silinecek özellikleri ve onsuz başarısız olacak özellikleri listeler. Bir özelliği silmeden veya değiştirmeden önce kullanın.", "help.timeline.play": "Kalan özellikleri, her yeniden hesaplamadan sonra yarım saniye bekleyerek tek tek yeniden oynatır. Durdurmak için bu komutu yeniden çalıştırın veya Esc tuşuna basın; Sona git hemen atlar.", + "help.view.apply": "Görünümü ön, üst veya izometrik gibi standart bir yöne ya da kayıtlı adlandırılmış bir görünüme çevirir. Modele sabit bir açıdan bakmak için kullanın.", + "help.view.delete": "Kayıtlı bir adlandırılmış görünümü siler. Model değişmez.", + "help.view.fit": "Tüm modelin veya seçimin görünümü doldurması için yakınlaştırır ve kaydırır. Model görüş alanından çıktığında kullanın.", + "help.view.grid": "Görünüm alanındaki ızgarayı gösterir veya gizler. Modelleme sırasında boyut ve yön kılavuzu olarak kullanın.", + "help.view.ground_shadow": "Modelin zemindeki gölgesini gösterir veya gizler. Yüksekliği ve konumu daha kolay değerlendirmek için kullanın.", + "help.view.home": "Görünümü ana yöne geri çevirir ve modeli sığdırır. Bilinen bir görünüme hızla dönmek için kullanın.", + "help.view.list": "Tasarımda kayıtlı adlandırılmış görünümleri listeler. Daha önce kaydettiğiniz bir görünümü bulmak için kullanın.", + "help.view.navigate": "Görünümü adımlarla, örneğin sola çeyrek tur, döndürür, kaydırır veya yakınlaştırır. Kamerayı sürüklemek yerine hassas biçimde hareket ettirmek için kullanın.", + "help.view.navigation_bar": "Görünüm alanının altındaki gezinme çubuğunu gösterir veya gizler. Daha fazla yer için gizleyin, görünüm araçlarına fareyle ulaşmak için gösterin.", + "help.view.rename": "Kayıtlı bir adlandırılmış görünüme yeni bir ad verir. Görünümün kendisi aynı kalır.", + "help.view.save": "Geçerli kamera konumunu tasarımda adlandırılmış bir görünüm olarak kaydeder. Daha sonra aynı açıya, örneğin ekran görüntüleri için, dönmek amacıyla kullanın.", + "help.view.spin": "Görünümü, bir sonraki tıklamaya veya tuşa kadar modelin etrafında sürekli döndürür. Bir modeli her yönden sunmak için kullanın.", + "help.view.tool": "Döndürme, kaydırma veya yakınlaştırma gibi bir kamera aracını sabitler; böylece siz kapatana kadar sol fare tuşuyla sürüklemek görünümü hareket ettirir. Trackpad'de veya orta fare tuşu olmadan kullanın.", + "help.view.visual_style": "Modelin nasıl çizileceğini değiştirir: kenarlı ya da kenarsız gölgeli, tel kafes veya gizli kenarlı. İçini görmek için tel kafes, temiz bir görünüm için gölgeli kullanın.", "history.sketch_under_constrained": "Eksik kısıtlanmış ({count} serbestlik derecesi)", "history.sketch_under_constrained_one": "Eksik kısıtlanmış ({count} serbestlik derecesi)", "hole_presets.confirm_delete": "Silmeyi onayla", @@ -1615,6 +1867,10 @@ "material.selection.mixed": "Farklı malzemeler", "measure.at": "Konum", "measure.item": "Öğe {number}", + "measure.quick_copied": "Ölçüm kopyalandı", + "measure.quick_copy": "Ölçümü kopyalamak için tıklayın", + "measure.quick_edges": "{count} kenar", + "measure.quick_faces": "{count} yüz", "messages.ago_h": "{n} sa. önce", "messages.ago_min": "{n} dk. önce", "messages.ago_s": "{n} sn. önce", @@ -1767,6 +2023,11 @@ "not a move": "bir taşıma değil", "not a number": "bir sayı değil", "open a sketch first": "önce bir eskiz açın", + "parameters.apply_live": "Her değişikliği alanından çıkarken uygula", + "parameters.apply_live_tip": "Kapalıyken: değişiklikler Uygula veya Tamam'a kadar bekler, ardından tasarım hepsi için bir kez yeniden oluşturulur. İptal veya Esc onları atar.", + "parameters.held": "Uygula'yı bekleyen {count} değişiklik", + "parameters.held_hint": "İfadeleri ve birimleri düzenleyin. Değişiklikler Uygula veya Tamam'a kadar bekler.", + "parameters.held_one": "Uygula'yı bekleyen 1 değişiklik", "parts.automatic_name": "otomatik", "parts.dimension.across_flats": "Anahtar ağzı", "parts.dimension.bore": "Delik çapı", @@ -1789,6 +2050,8 @@ "parts.family.set_screw": "Alyan tespit vidası, düz uçlu", "parts.family.socket_head_cap_screw": "Alyan başlı cıvata", "parts.family.washer": "Düz rondela", + "parts.holes": "Delikler (isteğe bağlı)", + "parts.placement_hint": "her birine bir parça yerleştirmek için bir veya daha fazla delik seçin ya da OK ile orijine yerleştirin", "pattern.alignment_2": "Hizalama 2", "pattern.alignment_hint": "Adet, orijinali içerir. Ortala, kopyaları iki yana dağıtır; çift adetlerde yönü ters çevirme, fazladan kopyanın hangi tarafta olacağını belirler.", "pattern.direction_1": "Yön 1", @@ -1955,6 +2218,8 @@ "prefs.origin_plane_names": "Orijin düzlemi adları", "prefs.origin_plane_names_note": "Tarayıcıda, diyaloglarda ve listelerde gösterilen adlar. Z yukarı olmak üzere {top} XY, {front} XZ ve {right} YZ'dir. Komutlar, betikler ve kayıtlı tasarımlar XY, XZ ve YZ adlarını korur.", "prefs.overall_speed": "Genel hız", + "prefs.parameter_list_rows": "Parametre listesi satırları", + "prefs.parameter_list_rows_tip": "Boş bir değer alanında Aşağı ok, parametreleri sık kullanılanlar önce olacak şekilde en fazla bu kadar satır listeler. Daha azı, büyük bir tasarımda listeyi hafif tutar.", "prefs.persistent_ribbon_pressed": "Etkin şerit komutlarını vurgula", "prefs.persistent_ribbon_pressed_help": "Şerit komutlarını, etkileri açık olduğu sürece vurgula. Önceki nötr görünümü ve komut yardımını korumak için kapatın.", "prefs.perspective_fov": "Görüş alanı", @@ -1984,6 +2249,8 @@ "prefs.quality_fastest": "en hızlı", "prefs.quality_slowest": "en yavaş", "prefs.quality_smoother": "daha pürüzsüz", + "prefs.quick_measure": "Görünümün köşesinde hızlı ölçüm", + "prefs.quick_measure_note": "Seçimin boyutu, görünümün sağ alt köşesinde: bir kenarın uzunluğu (yuvarlaksa Ø ve R), bir yüzün alanı, bir noktanın konumu, iki öğenin mesafesi ve açısı, birkaç kenarın veya yüzün toplamı. Tıklamak onu kopyalar.", "prefs.render_previous_geometry_budget": "Önceki Render önizleme geometri bütçesini kullan", "prefs.render_previous_geometry_budget_help": "Cihaza dayalı bütçe yerine önceki sabit geometri sınırını (64 bit masaüstünde 512 MiB, diğer yerlerde 128 MiB) geri getirir.", "prefs.reports_saved": "{n} kayıtlı", @@ -2029,6 +2296,8 @@ "prefs.sketch_tab_always_note": "Hiçbir eskiz açık değilken Eskiz sekmesi şeritte, Model’den hemen sonra kalır. Çizim araçları bu durumda seçili düzlemde veya yüzde ya da bir sonra seçtiğiniz düzlemde, araç hazır olarak bir eskiz başlatır.", "prefs.sketch_tracking_guides": "Merkez ve orta nokta hizalama kılavuzları", "prefs.sketch_tracking_guides_note": "Bir dairenin merkezinin veya düz bir kenarın orta noktasının üzerine gelin, ardından geçici noktalı bir kılavuz görmek için yatay veya dikey hareket ettirin.", + "prefs.sketch_tracking_points": "Çizim sırasında uç noktalardan ve köşelerden hizalama kılavuzları", + "prefs.sketch_tracking_points_note": "Bir çizim aracı bir nokta beklerken, bir çizgi veya yay ucunun, bir dikdörtgen köşesinin ya da serbest bir noktanın üzerine gelmek, bir merkez veya orta noktadaki gibi geçici noktalı kılavuzu gösterir. Kapalıyken: yalnızca merkezler ve orta noktalar.", "prefs.sketching": "Eskiz", "prefs.snap_drawing_note": "Bir eskiz noktası çizerken veya sürüklerken.", "prefs.source": "Kaynak", @@ -2045,6 +2314,7 @@ "prefs.status_error": "Hata", "prefs.status_selected": "Seçildi", "prefs.status_warning": "Uyarı", + "prefs.structure.scale_percent": "%{percent}", "prefs.supersampling_brief": "Görünümü daha büyük çizip aşağı süzer: çok net, ama daha ağır.", "prefs.tab_accent_note": "Vurgu rengi etkin sekmeyi izler", "prefs.theme_file": "tema dosyası", @@ -2056,6 +2326,16 @@ "prefs.theme_follow_off": "Kapalı", "prefs.theme_follow_stop": "Takibi durdur", "prefs.theme_import_hint": "Bir tema dosyası seçmek için tıklayın, buraya bırakın veya bir tema kopyalıyken Ctrl+V (Mac'te Cmd+V) tuşlarına basın", + "prefs.theme_option_opacity": "Opaklık", + "prefs.theme_option_opacity_help": "Kayan panellerin opaklığını ayarlayın. Bir okunabilirlik alt sınırı, görünüm alanı renkleri üzerindeki metni korur.", + "prefs.theme_option_tab_colours": "Sekme renkleri", + "prefs.theme_option_tab_colours_help": "Her şerit sekmesi için bu temanın panel ve görünüm alanı renklerini kullanın.", + "prefs.theme_option_tab_fade_ms": "Sekme geçişi", + "prefs.theme_option_tab_fade_ms_help": "Sekme değiştirirken panel ve görünüm alanı renkleri arasında yumuşak geçiş yapın. Sıfır, anında değiştirir.", + "prefs.theme_options_badge": "Seçenekler", + "prefs.theme_options_more": "Daha fazla · {count}", + "prefs.theme_options_reset": "Sıfırla", + "prefs.theme_options_unavailable": "Bu cihazda kullanılamıyor", "prefs.theme_paste": "Temayı yapıştır", "prefs.theme_paste_none": "Panoda SolidCraft teması yok", "prefs.timeline_half_hour": "−30 dk", @@ -2077,6 +2357,8 @@ "prefs.under_cursor": "İmlecin altında", "prefs.units_precision": "Birimler ve hassasiyet", "prefs.updates": "Güncellemeler", + "prefs.value_boxes_in_view": "Değerleri görünümde tutamaçlarının yanında göster", + "prefs.value_boxes_in_view_tip": "Bir komutun değeri (Mesafe, Yarıçap…), modelin üzerinde tutamacının yanındaki bir kutuda gösterilir. Kapalıyken: değeri, bir seçimden sonra klavyeyi alan komut penceresine yazarsınız ve hiçbir şey modeli örtmez.", "prefs.view": "Görünüm davranışı", "prefs.view_confirm_corner": "Görünümün köşesinde Tamam ve İptal", "prefs.view_confirm_corner_note": "Bir diyalog veya eskiz açıkken, görünümün sağ üstündeki bir onay işareti ve çarpı işareti, diyalogun Tamam ve İptal düğmelerinin ya da eskizi bitirip iptal etmenin işini yapar. Görünüm yönlendiricileri bunların altına iner.", @@ -2086,6 +2368,12 @@ "prefs.viewport_background": "Arka plan", "prefs.viewport_background_colour": "Renk", "prefs.viewport_background_theme": "Temanın degradesi", + "prefs.viewport_section_status": "Görünüm alanı kesit durumunu göster", + "prefs.viewport_section_status_help": "Kesit ayarını görünüm alanında gösterin. Esc ve etkin şerit düğmesi onu kapatır. Önceki kesit denetimlerini korumak için devre dışı bırakın.", + "prefs.viewport_status_details": "Canlı görünüm alanı ayrıntıları", + "prefs.viewport_status_details_help": "Etkin analiz ve kesit ayarlarını standart ayrıntı kartlarında düzenleyin. Salt okunur analiz göstergelerini ve Kesit diyaloğunu geri getirmek için devre dışı bırakın.", + "prefs.viewport_status_summary": "Görünüm alanı durum öğelerini daralt", + "prefs.viewport_status_summary_help": "Üç veya daha fazla görünüm alanı durum öğesini tek bir özet rozetinde toplayın. Her öğeyi tam satırda tutmak için devre dışı bırakın.", "prefs.wheel": "Tekerlek", "prefs.wheel_trackpad": "Tekerlek ve trackpad", "prefs.workspace": "Çalışma alanı", @@ -2964,7 +3252,9 @@ "render.output_ready": "Render için hazır", "render.output_run_check": "Kontrolü çalıştır", "render.output_size_limit": "{method}: kenar başına en fazla {dimension} px ve {pixels} piksel. Sığan en büyük boyut: {width} × {height}.", + "render.output_size_method_profile": "{width} × {height} boyutunu render etmek için {profile} CPU kaynak profiliyle {method} yöntemini seçin ya da daha küçük bir boyut kullanın.", "render.output_size_ok": "Boyut uygun · {width} × {height} px", + "render.output_size_profile": "{width} × {height} boyutunu render etmek için {profile} CPU kaynak profilini seçin ya da daha küçük bir boyut kullanın.", "render.output_swap_orientation": "Genişlik ve yüksekliği değiştir", "render.output_tools_inspector": "Boyutu ve biçimi Çıktı denetçisinde düzenleyin.", "render.output_view_image": "Görüntüyü göster", @@ -3177,6 +3467,7 @@ "render.size_custom": "Özel…", "render.size_full_hd": "Full HD", "render.size_hd": "HD", + "render.size_needs_profile": "{size} · {profile} gerekir", "render.size_presets": "Boyut ön ayarları", "render.size_presets_pending": "Çıktı düzenleyicisiyle gelecek", "render.size_preview": "Önizleme", @@ -3464,6 +3755,24 @@ "sheet.flange_type_contour": "Kontur", "sheet.flange_type_edge": "Kenar", "sheet.no_body": "sac metal gövde yok: Flanş > Taban ile bir tane başlatın veya bir plakayı Sac Metale Dönüştür ile sac metale çevirin (büyük düz yüzünü seçin)", + "sheet.personal_rules.confirm_delete": "Silmeyi onayla", + "sheet.personal_rules.delete": "Sil", + "sheet.personal_rules.delete_prompt": "Bu kişisel kural silinsin mi? Tasarımlardaki mevcut kurallar değişmez.", + "sheet.personal_rules.full": "Kişisel kitaplık zaten 64 kural içeriyor.", + "sheet.personal_rules.invalid_name": "Satır sonu veya kontrol karakteri içermeyen kısa bir ad girin.", + "sheet.personal_rules.invalid_values": "Kuralın değerlerini tamamlayın: her biri bu tasarımda geçerli bir değere çıkmalı; kalınlık pozitif, K faktörü 0 ile 1 arasında olmalıdır.", + "sheet.personal_rules.missing": "Seçilen kişisel kural artık kullanılamıyor.", + "sheet.personal_rules.name": "Kural adı", + "sheet.personal_rules.name_exists": "Bu adda bir kişisel kural zaten var. Farklı bir ad kullanın.", + "sheet.personal_rules.name_hint": "Benzersiz bir ad", + "sheet.personal_rules.overwrite": "Değişiklikleri seçili kişisel kurala kaydet", + "sheet.personal_rules.rename": "Yeniden adlandır", + "sheet.personal_rules.rule": "Kişisel kural", + "sheet.personal_rules.save": "Yeni kaydet", + "sheet.personal_rules.scope": "Tüm tasarımlar için profilinize kaydedilir.", + "sheet.personal_rules.select": "Bir kural seçin", + "sheet.personal_rules.title": "Kişisel kurallar", + "sheet.personal_rules.unsupported": "Bu kural, bu sürümün kullanamadığı değerler içeriyor.", "shortcut.json_error": "parametreler JSON biçiminde olmalıdır: {error}", "shortcut.no_command": "`{id}` komutu yok; JSON'dan önceki metin bir komut kimliği olmalıdır", "shortcut.pin": "S kutusunun üstüne sabitle", @@ -3500,6 +3809,7 @@ "sketch_text.align_right": "Sağ", "sketch_text.bold": "Kalın", "sketch_text.builtin_font": "{family} (yerleşik)", + "sketch_text.formula": "Kelimeleri parametrelerden oluşturmak için = ile başlayın: ='W' + number(Width)", "sketch_text.italic": "İtalik", "sketch_text.loading_fonts": "Yüklü yazı tipleri aranıyor…", "sketch_text.on_path": "Metin eğrisini izler: konumunu eğri belirler", @@ -3640,6 +3950,8 @@ "ui.3d_mouse_section": "3D fare", "ui.3d_mouse_show_in_view": "3B fareyi 3B görünümde göster", "ui.3d_mouse_show_in_view_tip": "Sol alt köşede, siz hareket ettirdikçe hareket eden bir 3B fare resmi.", + "ui.3d_mouse_sketch_orbit": "Eskizleri 3B fareyle döndür", + "ui.3d_mouse_sketch_orbit_tip": "Bir eskiz açıkken ve dik bakılırken 3B fare de, fare gibi, görünümü döndürür. Kapalıyken: görünümü eğene kadar eskizi kaydırır ve yakınlaştırır.", "ui.3d_mouse_skip": "Cihazım bunu yapamıyor", "ui.3d_mouse_skip_tip": "Bu hareketi iki yönde de atlar; kalibrasyon onu dışarıda bırakır.", "ui.3d_mouse_speed": "3D fare hızı", @@ -3708,6 +4020,7 @@ "ui.agent_chat_auth_terminal": "Bunu bir terminalde çalıştırın ve ardından tekrar Bağlan'a tıklayın:", "ui.agent_chat_auth_title": "Ajan için oturum aç", "ui.agent_chat_cancel": "İptal", + "ui.agent_chat_cannot_start": "Yüklü ama başlamıyor: {error}", "ui.agent_chat_changes": "{n} değişiklik, tek geri alma adımı", "ui.agent_chat_changes_n": "{n} değişiklik", "ui.agent_chat_changes_of": "{n}/{limit} değişiklik", @@ -3800,9 +4113,12 @@ "ui.agent_chat_queued_stopped": "Sırada (durduruldu, gönderilmedi)", "ui.agent_chat_refresh": "Yenile", "ui.agent_chat_refused": "Ajan için izin verilmiyor: {reason}", + "ui.agent_chat_reinstall": "{agent} yüklü ({program}), ancak SolidCraft denetlediğinde başlamadı: {error}\nKurulumu eksik olabilir veya başka türde bir bilgisayar için yapılmış olabilir. `{install}` ile yeniden yükleyin, bir terminalde sürümünü yazdırdığını doğrulayın, ardından Yenile'ye tıklayın.", "ui.agent_chat_remove": "Sıradan kaldır", "ui.agent_chat_remove_file": "Bu iletiden çıkar", "ui.agent_chat_remove_imported": "Bu iletiden çıkar; parça tasarımda kalır (Geri al onu kaldırır)", + "ui.agent_chat_reply_cut_off": "Yanıt bitmeden uzunluk sınırına ulaştı, bu yüzden eksik olabilir. Ajandan devam etmesini veya daha kısa bir yanıt vermesini isteyin.", + "ui.agent_chat_reply_declined": "Model bu iletiyi yanıtlamayı reddetti.", "ui.agent_chat_retry": "Yeniden dene", "ui.agent_chat_right": "Sağ", "ui.agent_chat_save": "Kaydet", @@ -3861,12 +4177,17 @@ "ui.ai_copy_suffix": "Kopya", "ui.ai_custom_url": "Özel URL", "ui.ai_default": "Varsayılan", + "ui.ai_edit": "Düzenle", + "ui.ai_edit_agent_title": "Ajanı düzenle", + "ui.ai_edit_gone": "Bu ajan kaldırıldı, bu yüzden kaydedilecek bir şey yok.", + "ui.ai_edit_tip": "Bu ajanın adını, komutunu veya uç noktasını değiştirin. Kayıtlı bir anahtar gizli kalır.", "ui.ai_http_busy": "uç nokta meşgul veya sınırda; daha sonra tekrar deneyin", "ui.ai_http_key": "uç nokta anahtarı reddetti: Ayarlar > AI altında kontrol edin", "ui.ai_http_not_found": "Uç nokta veya model bulunamadı: temel URL'yi ve model adını kontrol edin", "ui.ai_http_too_large": "konuşma bu uç nokta için çok büyük; yeni bir sohbet başlatın", "ui.ai_key": "API anahtarı", "ui.ai_key_hint": "isteğe bağlı; sistem anahtar zincirinde saklanır", + "ui.ai_key_keep_hint": "kayıtlı anahtarı korumak için boş bırakın", "ui.ai_key_needs_https": "Anahtar yalnızca HTTPS üzerinden veya bu bilgisayara gönderilir.", "ui.ai_key_note": "SolidCraft ayarlarında değil, sistem anahtar zincirinde saklanır. Yerel bir sunucu için boş bırakın.", "ui.ai_keychain_error": "Sistem anahtar zinciri reddetti: {error}", @@ -3898,6 +4219,7 @@ "ui.ai_request_timeout": "İstek zaman aşımı", "ui.ai_request_timeout_hint": "Bu uç noktaya tek bir model isteğinin 30 sn ile bir saat arasında ne kadar sürebileceği. Yavaş bir yerel model için yükseltin.", "ui.ai_reset": "Sıfırla", + "ui.ai_save": "Kaydet", "ui.ai_settings": "AI ayarları…", "ui.ai_show_features": "AI özelliklerini göster", "ui.ai_show_features_note": "Ajan sohbeti: zaten kullandığınız bir kodlama ajanının veya seçtiğiniz bir model uç noktasının açık tasarım üzerinde çalıştığı bir panel. Kapalı: AI düğmesi yok, kısayol yok, menü girişi yok, panel yok ve hiçbir şey aranmaz veya başlatılmaz.", @@ -4071,6 +4393,40 @@ "ui.design_properties.project": "Proje", "ui.design_properties.revision": "Revizyon", "ui.design_saved": "{path} konumuna kaydedildi", + "ui.details_access_caption": "Yüzler, bir aletin onlara seçilen yönden ulaşıp ulaşamayacağını gösterir.", + "ui.details_angle": "Açı", + "ui.details_continuity_caption": "Seçili birleşim yerini düzenleyin. Diğer birleşim yerleri kendi ayarlarını korur.", + "ui.details_continuity_stale": "Önceki geometri: bu birleşim yerini düzenlemek veya çerçevelemek için analizi yeniden çalıştırın.", + "ui.details_copy_list": "Listeyi kopyala", + "ui.details_curvature": "Eğrilik toleransı", + "ui.details_curvature_caption": "Yüzler, yerel eğrilik yarıçaplarına göre renklendirilir.", + "ui.details_direction": "Alet yönü", + "ui.details_draft_caption": "Yüzler, kalıptan çıkarma yönüne olan açılarına göre renklendirilir.", + "ui.details_edit_seam": "Birleşim yerini düzenle", + "ui.details_fixture": "Arayüz bileşeni örneği — yalnızca örnek veri", + "ui.details_fixture_caption": "Açıklayıcı değerler içeren arayüz bileşeni örneği; bu bir model sonucu değildir.", + "ui.details_fixture_problem": "Sorunlu örnek bulgu", + "ui.details_fixture_readout": "Örnek ölçülen değer", + "ui.details_flip": "Ters çevir", + "ui.details_gap": "Boşluk", + "ui.details_hatch": "Kesilen yüzleri tara", + "ui.details_jump": "Eğrilik sıçraması", + "ui.details_key": "Gösterge", + "ui.details_offset": "Ofset", + "ui.details_plane": "Düzlem", + "ui.details_pull": "Kalıptan çıkarma yönü", + "ui.details_radius": "Yarıçap", + "ui.details_readout": "Okunan değer", + "ui.details_samples": "Örnekler", + "ui.details_seam": "{body} · Kenar {edge}", + "ui.details_settings": "Ayarlar", + "ui.details_sharp_gap": "Keskin veya boşluk", + "ui.details_show_all": "Tüm {count} öğeyi göster", + "ui.details_smooth": "Pürüzsüz", + "ui.details_stripes": "Şeritler", + "ui.details_target": "Geometriyi çerçevele", + "ui.details_worst_seams": "En kötü birleşim yerleri", + "ui.details_zebra_caption": "Yansıyan şeritler, yüzlerin ne kadar pürüzsüz birleştiğini gösterir.", "ui.diameter": "Çap", "ui.dimension": "Ölçü", "ui.direct_interaction": "Doğrudan etkileşim", @@ -4131,6 +4487,7 @@ "ui.equation_y_of_x": "y(x)", "ui.equivalent_radius": "Eşdeğer yarıçap", "ui.expand": "Genişlet", + "ui.explode_needs_two_components": "Patlatılmış görünüm bileşenleri birbirinden ayırır, bu yüzden tasarımda iki veya daha fazla bileşen olmalıdır (Yeni bileşen, Tasarım ekle veya Gövdelerden bileşen oluştur)", "ui.exportDxf": "DXF olarak dışa aktar…", "ui.export_ellipsis_keymap": "Dışa aktar…", "ui.export_keymap_tip": "Tuşları, S kutusu favorilerini ve araç çubuğunu dosya olarak dışa aktarır", @@ -4419,12 +4776,17 @@ "ui.plugins_skipped": "{count} klasör okunamadı.", "ui.plugins_version": "{version}", "ui.point": "Nokta", + "ui.point_cloud_add_filter": "Filtre ekle…", "ui.point_cloud_auto_range": "Otomatik aralık", "ui.point_cloud_color_by": "Renklendirme ölçütü", "ui.point_cloud_file_colors": "Dosya renkleri", + "ui.point_cloud_filter_by": "Filtreleme ölçütü", "ui.point_cloud_height_high": "En yüksek", "ui.point_cloud_height_low": "En düşük", + "ui.point_cloud_legend_filter": "Yukarıdaki göstergede gösterilir ve gizlenir", "ui.point_cloud_no_file_colors": "Dosyada renk yok", + "ui.point_cloud_pause": "Duraklat", + "ui.point_cloud_play": "Oynat", "ui.point_cloud_single_color": "Tek renk", "ui.point_cloud_size": "Nokta boyutu", "ui.point_cloud_units": "Dosya birimleri", @@ -4453,6 +4815,7 @@ "ui.properties.inertia_y": "Iyx, Iyy, Iyz", "ui.properties.inertia_z": "Izx, Izy, Izz", "ui.properties.measuring": "Ölçülüyor…", + "ui.properties.measuring_bodies": "Ölçülüyor… {total} gövdeden {done} tanesi", "ui.properties.measuring_failed": "Ölçüm tamamlanmadan durdu.", "ui.properties.principal_axis": "Asal eksen {number}", "ui.properties.principal_moments": "Asal momentler", @@ -4520,6 +4883,7 @@ "ui.selectOther": "Diğerini seç…", "ui.selectTangentChain": "Teğet zinciri seç", "ui.select_all": "Tümünü seç", + "ui.select_components_by_face": "Bileşenleri seçin (bir yüz kendi bileşenini seçer)", "ui.selection": "Seçim", "ui.selection_modifiers": "Seçim değiştiricileri", "ui.selection_shift_add_only": "Shift+tıklama kaldırmadan ekler", @@ -4561,6 +4925,11 @@ "ui.sketch_intersect_selection_filter": "Seçim filtresi", "ui.sketch_modifiers": "Eskiz değiştiricileri", "ui.sketch_project_geometry": "Geometri", + "ui.sketch_report.all_constrained": "Her şey tam kısıtlı.", + "ui.sketch_report.dof": "Serbestlik dereceleri", + "ui.sketch_report.loose": "Tam kısıtlı değil", + "ui.sketch_report.status": "Durum", + "ui.sketch_report.title": "Eskiz raporu", "ui.sketch_scale": "Eskiz ölçeği", "ui.sketches": "Eskizler", "ui.slice": "Dilim", @@ -4670,7 +5039,14 @@ "ui.viewport_status_key": "Göstergeyi göster: {name}", "ui.viewport_status_minimum_radius": "Minimum yarıçap", "ui.viewport_status_pairs": "{count} çift", + "ui.viewport_status_section_hatch": "{setting} · tarama", + "ui.viewport_status_section_plane": "Geçerli düzlem", + "ui.viewport_status_section_tilted": "eğik · {offset}", "ui.viewport_status_stripes": "{count} şerit", + "ui.viewport_status_summary_clear": "Hepsini kapat", + "ui.viewport_status_summary_heading": "Bu görünümde", + "ui.viewport_status_summary_label": "{name} ve {count} öğe daha", + "ui.viewport_status_summary_more": "+{count}", "ui.viewport_theme_follow": "Temayı izle", "ui.visual_shaded_hidden_edges": "Gizli kenarlarla gölgeli", "ui.visual_shaded_with_edges": "Kenarlarla gölgeli", diff --git a/assets/locales/uk.json b/assets/locales/uk.json index 7431f51f3e..2ee5bcc62c 100644 --- a/assets/locales/uk.json +++ b/assets/locales/uk.json @@ -60,6 +60,7 @@ "BLEND SURFACE": "ПОВЕРХНЯ ПЕРЕХОДУ", "BOUNDARY SURFACE": "ПОВЕРХНЯ ЗА МЕЖАМИ", "BROWSER": "БРАУЗЕР", + "Back to the original order": "Повернути початковий порядок", "Ball": "Кульове", "Bar": "Смуга", "Base Center": "Центр основи", @@ -595,6 +596,8 @@ "Snap to face and edge centres": "Прив'язка до центрів граней і ребер", "SolidCraft cannot submit reports directly. Submit on GitHub; no token is needed in the app.": "SolidCraft не може надсилати звіти напряму. Надішліть їх на GitHub; токен у застосунку не потрібен.", "SolidCraft closed before these designs were saved. Their last autosave can be opened.": "SolidCraft закрився до того, як ці проєкти було збережено. Можна відкрити їхнє останнє автозбереження.", + "Sort A to Z": "Сортувати від А до Я", + "Sort Z to A": "Сортувати від Я до А", "Spacing 2 must be a non-zero number": "Крок 2 має бути ненульовим числом", "Spacing must be a non-zero number": "Крок має бути ненульовим числом", "Specialise": "Спеціалізувати", @@ -819,6 +822,7 @@ "cmd.appearance_pick_up": "Взяти вигляд", "cmd.appearance_replace": "Замінити вигляд", "cmd.assembly_bom": "Специфікація матеріалів", + "cmd.assembly_mass_properties": "Масові характеристики", "cmd.body.print_face": "Грань друку", "cmd.body_rename": "Перейменувати тіло", "cmd.browser_collapse_all": "Згорнути все", @@ -1244,6 +1248,7 @@ "cmd.view.render_browser": "Браузер рендерингу", "cmd.view.render_dock": "Перемістити док рендерингу", "cmd.view.render_focus": "Вид фокусу рендерингу", + "cmd.view.status_details": "Подробиці рядка стану", "cmd.view.status_items": "Елементи рядка стану", "cmd.view_ambient_occlusion": "Окружне затінення", "cmd.view_angle_readout": "Кути виду", @@ -1387,21 +1392,168 @@ "from the rule": "з правила", "help.apache": "Ліцензія Apache 2.0", "help.appearance.assign": "Надає вибраним тілам, граням або компонентам колір чи вигляд матеріалу, не змінюючи їхньої ваги. Використовуйте, щоб розрізнити деталі або переглянути оздоблення.", + "help.assembly.bom": "Перелічує деталі проєкту або компонента з їхньою кількістю за номером деталі й може зберегти перелік як CSV або JSON. Використовуйте як список деталей для замовлення та складання.", "help.attributions": "Відомості про авторство (ATTRIBUTION.md)", "help.build": "Версія {version} · коміт {commit} · зібрано {date}", + "help.canvas.decal": "Розміщує зображення на плоскій грані моделі, наче наклейку. Використовуйте, щоб показати етикетку, логотип чи надрукований візерунок на деталі.", + "help.canvas.edit": "Змінює полотно: його положення, розмір, кут, непрозорість, віддзеркалення, видимість або назву. Використовуйте, щоб сумістити опорне зображення з ескізом, який ви обводите по ньому.", + "help.canvas.insert": "Розміщує зображення PNG або JPEG на площині чи грані як прозорий орієнтир. Використовуйте, щоб обвести фото чи креслення в ескізі, задавши зображенню відомий розмір.", + "help.component.break_link": "Відокремлює пов'язаний компонент від файлу його проєкту, залишаючи його тіла без змін. Використовуйте, коли деталь більше не має слідувати за змінами в початковому файлі.", + "help.component.capture_position": "Фіксує положення деталей, які ви пересунули вручну, і вони стають їхнім новим положенням. Використовуйте після перетягування на місце деталей, яких не утримує жодне з'єднання.", + "help.component.create": "Додає до проєкту новий порожній компонент усередині активного компонента, якщо ви не вибрали іншого батьківського. Використовуйте, щоб дати новій частині збірки власне місце для тіл і ескізів.", "help.component.finish_edit": "Зберігає деталь, яку ви редагуєте на місці, повертається до її збірки й оновлює збірку за цією деталлю. Використовуйте, коли зміни в пов'язаній деталі завершено.", + "help.component.from_bodies": "Перетворює кожне вибране тіло на окремий компонент із назвою цього тіла. Використовуйте, коли проєкт, збудований із окремих тіл, має стати збіркою, деталі якої можна з'єднувати й переміщувати.", + "help.component.insert_design": "Вставляє інший збережений проєкт як компонент, пов'язаний зі своїм файлом, із суцільними тілами в тому вигляді, як вони є там. Використовуйте, щоб повторно застосувати деталь у збірці й підхопити пізніші зміни через «Оновити зв'язки».", + "help.component.properties": "Показує об'єм, масу, центр мас і моменти інерції компонента за матеріалами його тіл. Використовуйте, щоб перевірити вагу й баланс збірки.", + "help.component.revert_position": "Повертає деталі, які ви пересунули вручну, на попередні місця, відкидаючи переміщення, що не були зафіксовані. Використовуйте, щоб скасувати пробні переміщення в збірці.", + "help.component.update_links": "Знову зчитує пов'язані файли проєктів і замінює тіла там, де вони змінилися. Використовуйте після редагування деталі, яку використовує ця збірка.", + "help.config.configure": "Додає параметри, операції, матеріали чи вигляди як стовпці таблиці конфігурацій. Використовуйте, щоб створити варіанти одного проєкту, наприклад розміри деталі.", + "help.config.preview": "Перевіряє, чи перебудовується рядок таблиці конфігурацій, не змінюючи проєкт чи його історію. Використовуйте перед перемиканням на варіант, у якому ви не впевнені.", + "help.config.rules": "Показує правила таблиці конфігурацій, її рядки та активний рядок. Використовуйте, щоб побачити, як налаштовано варіанти проєкту.", + "help.config.table": "Показує таблицю конфігурацій: по одному рядку на варіант, по одному стовпцю на параметр чи операцію, а також чи проєкт досі відповідає активному рядку. Використовуйте, щоб переглянути варіанти проєкту.", "help.connect_note": "Перезапустіть кожного асистента, щоб завантажити сервер SolidCraft. Перед зміною кожен файл збережено як резервну копію .bak.", "help.connect_title": "Підключення ШІ-асистентів", + "help.construct.axis.edge": "Створює допоміжну вісь уздовж прямого ребра або прямої ескізу. Використовуйте як вісь для обертання, кругового масиву чи з'єднання.", + "help.construct.axis.normal_to_face": "Створює допоміжну вісь через точку, перпендикулярну до грані. Використовуйте як вісь, що виходить прямо з поверхні.", + "help.construct.axis.through_cylinder": "Створює допоміжну вісь через центр грані циліндра, конуса чи тора або кругового ребра. Використовуйте, щоб обертати чи розмножувати операції навколо круглої деталі.", + "help.construct.axis.two_planes": "Створює допоміжну вісь там, де сходяться дві площини або плоскі грані. Використовуйте, коли потрібна вісь — лінія між двома гранями.", + "help.construct.axis.two_points": "Створює допоміжну вісь через дві точки, вершини або точки ескізу. Використовуйте для осі, що проходить між двома відомими положеннями.", + "help.construct.plane.along_path": "Створює допоміжну площину, перпендикулярну до ребра чи кривої, у вибраному місці вздовж неї. Використовуйте, щоб накреслити профіль для протягування або переріз траєкторії.", + "help.construct.plane.at_angle": "Створює допоміжну площину, повернуту на кут від площини чи грані навколо осі або ребра. Використовуйте для похилих ескізів, наприклад косого кронштейна.", + "help.construct.plane.midplane": "Створює допоміжну площину посередині між двома площинами чи плоскими гранями або таку, що ділить навпіл кут між ними. Використовуйте як площину симетрії для дзеркального відображення.", + "help.construct.plane.offset": "Створює допоміжну площину, паралельну площині чи плоскій грані, на відстані або через точку. Використовуйте, щоб креслити на заданій висоті над гранню.", + "help.construct.plane.perpendicular": "Створює допоміжну площину, що містить пряму чи ребро й стоїть перпендикулярно до площини або грані. Використовуйте, щоб креслити на моделі вздовж ребра в прямовисній площині.", + "help.construct.plane.tangent": "Створює допоміжну площину, що дотикається до циліндра, конуса, кулі чи тора в точці. Використовуйте, щоб креслити на боці круглої деталі.", + "help.construct.plane.three_points": "Створює допоміжну площину через три точки. Використовуйте для площини, заданої положеннями на моделі, наприклад трьома кутами.", + "help.construct.plane.two_edges": "Створює допоміжну площину через два прямі ребра чи прямі, що лежать в одній площині. Використовуйте для площини через деталь, наприклад через два протилежні ребра.", + "help.construct.point.along_path": "Створює допоміжну точку в певному місці вздовж ребра чи кривої. Використовуйте як орієнтир на півшляху вздовж траєкторії.", + "help.construct.point.center": "Створює допоміжну точку в центрі кругового ребра, кола чи дуги ескізу, кулі або тора. Використовуйте як орієнтир для отворів, осей і з'єднань.", + "help.construct.point.edge_plane": "Створює допоміжну точку там, де ребро чи пряма проходить крізь площину або плоску грань. Використовуйте, щоб позначити, де траєкторія перетинає площину.", + "help.construct.point.three_planes": "Створює допоміжну точку там, де сходяться три площини чи плоскі грані. Використовуйте як орієнтир кута, навіть якщо в моделі немає вершини.", + "help.construct.point.two_edges": "Створює допоміжну точку там, де два ребра чи прямі сходяться або підходять найближче одне до одного. Використовуйте як орієнтир у віртуальному куті.", + "help.construct.point.vertex": "Створює допоміжну точку на вершині або точці ескізу. Використовуйте як орієнтир, що залишається, коли навколишній ескіз чи тіло приховано.", + "help.contact.check": "Перелічує пари деталей у наборах контактів, що проходять одна крізь одну в поточному положенні. Використовуйте, щоб знайти зіткнення перед керуванням з'єднаннями чи запуском дослідження руху.", + "help.contact.create": "Об'єднує дві чи більше деталей у набір контактів, тіла якого не можуть проходити одне крізь одне, поки рухаються з'єднання. Використовуйте для деталей, що мають зупинятися при дотику, наприклад важеля біля свого упору.", + "help.contact.disable_all": "Вимикає контакт, тож керовані з'єднання та дослідження руху більше не зупиняються там, де деталі торкаються. Використовуйте, щоб знову вільно рухати деталі або щоб велика збірка швидко реагувала.", + "help.contact.edit": "Змінює набір контактів: деталі в ньому, його назву або те, чи його придушено; також може видалити набір. Використовуйте, щоб налаштувати, які деталі не можуть проходити одна крізь одну.", + "help.contact.enable_all": "Поміщає кожну деталь збірки в один набір контактів, тож жодні дві деталі не можуть проходити одна крізь одну, поки рухаються з'єднання. Використовуйте для швидкої перевірки всього механізму.", + "help.contact.enable_sets": "Вмикає контакт для заданих вами наборів контактів, тож керовані з'єднання та дослідження руху зупиняються там, де їхні деталі проходили б одна крізь одну. Використовуйте, щоб повернути ваші набори після вимкнення контакту.", + "help.contact.list": "Перелічує набори контактів проєкту, деталі в кожному з них і чи ввімкнено контакт. Використовуйте, щоб перевірити налаштування контакту перед керуванням збіркою.", + "help.document.inspect": "Показує огляд проєкту: його компоненти, тіла, ескізи, операції та параметри. Використовуйте, щоб зорієнтуватися в проєкті, який ви створювали не самі.", + "help.document.units": "Змінює одиниці, в яких проєкт показує довжини, наприклад mm або дюйми. Геометрія зберігає розмір; змінюються лише відображення й прості числа.", + "help.edit.checkpoint": "Запам'ятовує проєкт, активний ескіз і історію скасувань у поточному стані. Використовуйте перед ризикованою спробою, від якої, можливо, захочете повернутися.", + "help.edit.restore_checkpoint": "Повертається до контрольної точки разом з історією скасувань. Використовуйте, щоб відкинути все зроблене після контрольної точки одним кроком.", + "help.explode.create": "Створює рознесений вид, що розсовує деталі від центра збірки або на задані вами переміщення. Використовуйте, щоб показати, як збірку складають, для інструкцій чи презентацій.", "help.face.delete": "Видаляє вибрані грані, а сусідні грані закривають проміжок, або залишає його відкритим як поверхню. Використовуйте, щоб прибрати заокруглене ребро, отвір чи бобишку, що заважають.", + "help.file.insert_mesh": "Додає файл 3MF, STL, OBJ, PLY або glTF до проєкту як тіла сітки в новому компоненті. Використовуйте, щоб розмістити завантажену чи відскановану модель поруч із проєктом як орієнтир.", + "help.file.insert_step": "Додає файл STEP, IGES, FreeCAD або Rhino до проєкту як суцільні тіла в новому компоненті; деталі збірки стають вкладеними компонентами. Використовуйте, щоб імпортувати моделі від постачальників чи з інших CAD-програм.", + "help.file.save_mesh": "Зберігає тіла як сітку STL, 3MF або OBJ із вибраною вами гладкістю криволінійних граней. Використовуйте, щоб підготувати деталі до 3D-друку або для програм, які читають сітки.", + "help.file.send_to_slicer": "Зберігає вибрані тіла як файл 3MF і відкриває його у вибраному вами слайсері. Використовуйте, щоб перейти від моделі до друку одним кроком.", "help.font_details": "Інтерфейс використовує вбудовані шрифти egui (Ubuntu Light і Hack під ліцензіями Ubuntu Font Licence та MIT/Bitstream Vera; Noto Emoji та шрифт піктограм емодзі під ліцензіями OFL і MIT). Для тексту ескізів використовується Liberation Sans (SIL OFL 1.1). Необов’язковий пакет шрифтів The Crafting Apps, craft-fonts (BIZ UDPGothic, Shippori Mincho, BIZ UDMincho, Noto Sans CJK SC, Noto Sans Arabic; SIL OFL 1.1), не входить до збірок SolidCraft.", "help.fonts": "Шрифти", + "help.frame.member": "Розміщує профіль, наприклад трубу чи алюмінієвий профіль, уздовж прямих ескізу рами, по одному елементу на пряму. Використовуйте, щоб будувати рами, стелажі та зварні конструкції.", + "help.inspect.accessibility": "Фарбує грані, до яких інструмент може дістатися з одного напрямку, і ті, до яких не може. Використовуйте, щоб перевірити деталь на можливість обробки чи формування з цього боку.", + "help.inspect.center_of_mass": "Показує центр мас тіл — кожного окремо й усіх разом — за однорідної густини. Використовуйте, щоб перевірити баланс і те, куди деталь перекинеться.", + "help.inspect.colored_components": "Надає кожному компоненту власний тимчасовий колір, не змінюючи вигляди. Використовуйте, щоб одразу розрізняти деталі збірки.", + "help.inspect.compare": "Порівнює два тіла й показує, де в одного є матеріал, якого немає в іншого, з об'ємом і відхиленням. Використовуйте, щоб порівняти змінену чи імпортовану деталь з оригіналом.", + "help.inspect.continuity": "Перевіряє, наскільки гладко грані сходяться вздовж ребер: проміжки, злами чи стрибки кривизни, і розфарбовує шви. Використовуйте, щоб знайти місця, де поверхні сполучаються нечисто.", + "help.inspect.curvature_comb": "Малює гребені вздовж кривих, ребер чи граней, зубці яких показують, наскільки сильно вони згинаються. Використовуйте, щоб помітити плоскі ділянки, злами та нерівномірну кривизну.", + "help.inspect.curvature_map": "Розфарбовує грані за силою їхньої кривизни: червоним там, де радіус малий. Використовуйте, щоб знайти тісні місця й оцінити, наскільки гладка поверхня.", + "help.inspect.draft": "Розфарбовує грані за кутом ухилу відносно напрямку витягування, позначаючи ті, де його замало. Використовуйте, щоб перевірити деталь перед формуванням чи литтям.", + "help.inspect.environment_map": "Показує модель із відбивним, дзеркальним оздобленням. Використовуйте, щоб оцінити перебіг поверхонь за тим, як по них біжать відбиття.", + "help.inspect.interference": "Знаходить тіла, що перетинаються, і показує кожен перетин з його об'ємом. Використовуйте, щоб перевірити збірку на деталі, які зіштовхуються.", + "help.inspect.isocurve": "Малює ізопараметричні криві граней — сітку, на якій побудована поверхня. Використовуйте, щоб побачити, як тече поверхня і де вона згущується.", + "help.inspect.measure": "Вимірює довжини, відстані, кути, площі та радіуси між вибраними об'єктами або об'єм і масу тіл. Використовуйте, щоб перевірити розміри й зазори.", + "help.inspect.minimum_radius": "Знаходить найменший радіус кривизни кривих, ребер чи граней і те, де він розташований. Використовуйте, щоб перевірити, чи може інструмент або сопло пройти за формою.", + "help.inspect.section": "Розрізає вид уздовж площини та штрихує перерізані грані, не змінюючи модель. Використовуйте, щоб зазирнути всередину деталей і збірок.", + "help.inspect.zebra": "Показує чорно-білі смуги, що відбиваються на гранях моделі. Використовуйте, щоб оцінити гладкість: смуги, які обриваються чи ламаються на ребрі, вказують на поганий перехід.", + "help.joint.as_built": "З'єднує дві деталі там, де вони вже є, не переміщуючи жодну з них, і задає, як вони ще можуть рухатися. Використовуйте для деталей, які ви вже поставили в остаточне положення.", + "help.joint.cam": "Зв'язує кулачок, що обертається, зі штовхачем, тож штовхач ковзає чи гойдається, коли контур кулачка проходить під ним. Використовуйте для кулачків, ексцентриків і приводів клапанів.", + "help.joint.create": "З'єднує дві деталі у вибраних початках з'єднання й переміщує другу деталь на місце; тип з'єднання задає, як вони ще можуть рухатися. Використовуйте, щоб створити збірку, деталі якої рухаються як справжній механізм.", + "help.joint.drive": "Задає кут чи відстань з'єднання й переміщує разом з ним деталі в межах його обмежень; пов'язані з'єднання та замкнені контури слідують за ним. Використовуйте, щоб перевірити, що механізм рухається як задумано.", + "help.joint.gear": "Зв'язує два обертові з'єднання передавальним числом, тож рух одного обертає інше. Використовуйте для прямозубих коліс і зубчастих вінців, із передавальним числом за радіусами ділильних кіл або кількістю зубів.", + "help.joint.limits": "Задає, наскільки далеко з'єднання може повернутися чи зсунутися, мінімумом і максимумом, або скасовує ці межі. Використовуйте, щоб утримати шарнір, повзун чи важіль у межах його справжнього ходу.", + "help.joint.motion_link": "Пов'язує два з'єднання співвідношенням і зсувом, тож рух одного рухає інше. Використовуйте для сталого зв'язку між з'єднаннями, якого не охоплюють «Зубчаста передача», «Рейкова передача» чи «Гвинт».", + "help.joint.origin": "Розміщує іменований початок з'єднання на деталі: у центрі грані чи круглого торця, на середині ребра, у куті або в точці. Використовуйте, щоб підготувати місця кріплення з'єднань; початок, вибраний на грані, слідує за нею, коли деталь змінює розмір.", + "help.joint.rack_pinion": "Зв'язує шестірню, що обертається, з рейкою, що ковзає, тож рейка переміщується на довжину ділильного кола, яку прокочує шестірня. Використовуйте для рейкових приводів, рульового керування й лінійних столиків.", + "help.joint.reach": "Переміщує точку на деталі до цілі, змінюючи з'єднання на її ланцюзі в межах їхніх обмежень. Використовуйте, щоб поставити механізм у позу за тим, де має бути його кінець, наприклад кінчик руки робота.", + "help.joint.rigid_group": "Скріплює вибрані деталі як є, тож вони рухаються як одне ціле. Використовуйте для деталей, що належать одна до одної й не мають руху між собою.", + "help.joint.screw": "Зв'язує обертання й ковзання кроком, тож кожен оберт зсуває на цю відстань. Використовуйте для ходових гвинтів, болтів і різьбових шпинделів.", + "help.library.apply": "Застосовує матеріал чи вигляд із файлу локальної бібліотеки до тіл, граней або компонентів. Використовуйте, щоб повторно застосувати матеріали, збережені з іншого проєкту.", + "help.library.export": "Зберігає матеріали та вигляди проєкту у файл бібліотеки. Використовуйте, щоб ділитися ними або повторно застосовувати в інших проєктах.", + "help.library.inspect": "Перевіряє файл бібліотеки та перелічує його матеріали й вигляди, не змінюючи проєкт. Використовуйте, щоб побачити, що містить бібліотека, перш ніж її застосовувати.", "help.licences": "Ліцензії", "help.material.assign": "Задає, з чого зроблено вибрані тіла, наприклад зі сталі, алюмінію чи ABS. Використовуйте, щоб маса й вага відповідали справжній деталі.", + "help.material.properties.validate": "Перевіряє картку даних матеріалу на узгодженість чисел і пропущені значення. Використовуйте перед тим, як покладатися на ці дані для маси чи жорсткості.", + "help.mesh.convert": "Перебудовує тіло сітки, наприклад STL, у суцільне тіло з плоскими й циліндричними гранями, яке можна редагувати. Використовуйте, щоб змінювати імпортовані механічні деталі як змодельоване тіло.", "help.mit": "Ліцензія MIT", + "help.motion.study": "Задає значення з'єднань на кроках уздовж часової шкали й відтворює їх як анімацію. Використовуйте, щоб спостерігати рух механізму за послідовністю й перевіряти зазори по дорозі.", + "help.motion.trace": "Відстежує траєкторію, якою рухається точка деталі під час дослідження руху чи керування з'єднанням, і після підтвердження зберігає її як криву 3D-ескізу. Використовуйте, щоб побачити, куди сягає механізм, або щоб будувати на його траєкторії.", "help.notice": "NOTICE", + "help.occurrence.ground": "Закріплює деталь на місці або знову звільняє її, тож з'єднання переміщують інші деталі навколо неї. Використовуйте для рами чи основи, на якій будується решта збірки.", "help.occurrence.move": "Переміщує екземпляр компонента в збірці", "help.parameters.change": "Відкриває таблицю іменованих значень і виразів, що керують розмірами проєкту. Використовуйте, щоб змінити розмір в одному місці, а всі операції, які його використовують, підлаштувалися.", + "help.parameters.export": "Зберігає параметри проєкту як CSV або JSON. Використовуйте, щоб редагувати їх у таблиці або перенести до іншого проєкту.", + "help.parameters.graph": "Показує, які параметри й операції використовують які інші, а також цикли чи помилки між ними. Використовуйте, щоб побачити, на що вплине зміна параметра.", + "help.parameters.import": "Зчитує параметри з CSV або JSON у проєкт: усі або жодного. Використовуйте, щоб імпортувати значення з таблиці чи іншого проєкту.", + "help.parameters.move": "Переміщує параметр користувача перед іншим або після нього в списку. Використовуйте, щоб тримати пов'язані параметри разом.", + "help.parts.fastener": "Додає стандартний гвинт, болт, гайку, шайбу, штифт чи підшипник вибраного розміру. Розміщений на отворі, він сідає в отвір із з'єднанням, тож вам не потрібно моделювати кріплення самостійно.", + "help.parts.insert": "Вставляє стандартну деталь вибраного сімейства, розміру й довжини. Розміщена на краю чи стінці отвору, вона сідає на отвір із жорстким з'єднанням.", + "help.parts.library": "Відкриває стандартні деталі: гвинти, болти, гайки, шайби, установчі гвинти, штифти та підшипники стандартних розмірів. Використовуйте, щоб додати стандартну деталь замість того, щоб моделювати її самостійно.", + "help.parts.resize": "Змінює розмір чи довжину вставленої стандартної деталі та перебудовує її; її з'єднання тримають її на місці. Використовуйте, коли гвинт виявився задовгим або отвору потрібен інший розмір.", + "help.plastic.assign_rule": "Призначає тілам правило пластику, тож їхні пластикові операції беруть з нього ухил і зазор за замовчуванням. Використовуйте, щоб один раз налаштувати формовану деталь для всіх її операцій.", + "help.plastic.boss": "Додає бобишку під гвинт на грані: стовпчик із необов'язковими отвором, ухилом, заокругленням біля основи та ребрами жорсткості. Використовуйте для точок кріплення гвинтів у формованих чи надрукованих корпусах.", + "help.plastic.lip": "Додає губку або відповідний паз уздовж краю порожнистого тіла. Використовуйте, щоб дві половини корпусу центрувалися одна на одній і щільно закривалися.", + "help.plastic.manage_rules": "Створює й редагує правила пластику: матеріал, товщину стінки, радіуси, зазор і ухил. Використовуйте їх, щоб операції формованої деталі були узгодженими.", + "help.plastic.rest": "Додає упор на грані: круглий чи прямокутний майданчик, суцільний або порожнистий, з ухилом. Використовуйте як опору чи стійку у формованому корпусі.", + "help.plastic.snap_fit": "Додає клацковий гачок на грані із заданими довжиною, товщиною та зачепом. Використовуйте, щоб скріпити деталі защіпкою без гвинтів.", "help.pointcloud.insert": "Вставте скан у форматі PLY, XYZ або PTS як хмару точок поряд із моделлю. Використовуйте її як довідку під час побудови моделі за виміряними даними.", + "help.sheet.convert": "Перетворює пластину або порожнисте тіло рівномірної товщини на тіло листового металу зі згинами на вибраних ребрах. Використовуйте, щоб зробити наявну деталь придатною до розгортання.", + "help.sheet.export_bend_table": "Зберігає таблицю згинів тіла листового металу як CSV або JSON: кути, радіуси та положення згинів на розгортці. Використовуйте для листогибочного преса або цеху.", + "help.sheet.export_dxf": "Зберігає розгортку як DXF із контурами, лініями згинів і за бажанням примітками до згинів. Використовуйте для лазерного, плазмового чи гідроабразивного різання.", + "help.sheet.flange": "Додає фланець: основну пластину з ескізу, стінку вздовж розімкненого контуру ескізу або зігнуту стінку на краях листа. Використовуйте, щоб будувати деталі з листового металу.", + "help.sheet.flat_pattern": "Створює розгортку тіла листового металу з її розміром, вирізами та лініями згинів. Використовуйте, щоб перевірити заготовку перед різанням.", + "help.sheet.fold": "Згинає лист уздовж прямої ескізу на заданий кут і радіус. Використовуйте, щоб зігнути плоский лист після вирізання в ньому елементів.", + "help.sheet.hem": "Загинає край листа назад на себе. Використовуйте для безпечних, жорстких країв без гострих задирок.", + "help.sheet.manage_rules": "Створює й редагує правила листового металу: товщину, радіус згину, коефіцієнт K, полегшення та проміжки. Використовуйте їх, щоб узгодити матеріал та інструменти вашої майстерні.", + "help.sheet.path_flange": "Додає ланцюг фланців уздовж краю листа за розімкненим ланцюгом прямих ескізу. Використовуйте для профілів, таких як U- чи Z-подібний, одним кроком.", + "help.sheet.perforate": "Додає поле отворів по всьому плоскому листу в шаховому чи квадратному розташуванні. Використовуйте для вентиляційних отворів, решіток і полегшених панелей.", + "help.sheet.refold": "Знову згинає розгорнуте тіло листового металу. Використовуйте після додавання елементів до плоского листа.", + "help.sheet.unfold": "Розгортає всі згини тіла листового металу. Використовуйте, щоб додати отвори чи вирізи, які мають перетинати згин, а потім знову зігнути.", + "help.sketch.arc.center_point": "Малює кругову дугу навколо центральної точки від початкової до кінцевої точки. Використовуйте, коли відомий центр дуги, наприклад радіус навколо отвору чи осі повороту.", + "help.sketch.arc.tangent": "Малює дугу, що починається в кінці прямої чи дуги й продовжує її по дотичній. Використовуйте, щоб продовжити ланцюг плавним згином замість кута.", + "help.sketch.arc.three_point": "Малює дугу від початкової до кінцевої точки через третю точку. Використовуйте, коли дуга має проходити через задані точки, а не навколо відомого центра.", + "help.sketch.arc3d": "Малює точну кругову дугу в просторі через три точки за ввімкненого 3D-ескізу. Використовуйте для згинів траєкторій і напрямних, що виходять із площини ескізу.", + "help.sketch.auto_constrain": "Додає обмеження, які ескіз майже вже має, як-от горизонтальність, паралельність чи співпадання, а потім проставляє розміри, доки він не стане повністю визначеним. Використовуйте, щоб зафіксувати приблизно накреслений ескіз одним кроком.", + "help.sketch.auto_constrain_from_datum": "Працює як «Автообмеження», але вимірює положення від вибраної вами базової точки замість початку координат. Використовуйте, коли деталь розмічено від однієї опорної точки, наприклад кута чи отвору.", + "help.sketch.blend_curve": "З'єднує кінці двох кривих плавним сплайном, дотичним до обох, або також неперервним за кривизною, якщо ви так виберете. Використовуйте, щоб перекрити проміжок між кривими без зламу.", + "help.sketch.break": "Розбиває криву на частини там, де її перетинають інші криві, або в точках, які ви на ній розмістите. Використовуйте, коли частина кривої потребує власного обмеження або її слід видалити окремо.", + "help.sketch.cancel": "Виходить з ескізу й відкидає все, що змінено відтоді, як його створено чи відкрито для редагування. Скасування дії повертає зміни.", + "help.sketch.centerline": "Перемикає прямі між звичайними та осьовими лініями — осями, які профілі не враховують. Використовуйте осьову лінію як вісь для обертання, дзеркала чи обмеження симетрії.", + "help.sketch.chamfer.distance_angle": "Скошує кут між двома прямими, заданий відстанню вздовж першої прямої та кутом. Використовуйте, коли на кресленні фаску задано довжиною й кутом, наприклад 2 mm × 30°.", + "help.sketch.chamfer.equal_distance": "Скошує кут між двома прямими на однакову відстань уздовж обох. Використовуйте для звичайної фаски 45° на контурі.", + "help.sketch.chamfer.two_distance": "Скошує кут між двома прямими, заданий окремою відстанню вздовж кожної прямої. Використовуйте для фасок, що не є 45°, заданих двома довжинами.", + "help.sketch.circle.center": "Малює коло за центром і діаметром. Використовуйте для отворів, бобишок та інших круглих елементів, центр яких відомий.", + "help.sketch.circle.three_point": "Малює коло через три точки. Використовуйте, коли коло має проходити через задані точки, наприклад наявної геометрії.", + "help.sketch.circle.three_tangent": "Малює коло, що дотикається до трьох прямих, кіл або дуг. Використовуйте, щоб вписати коло в простір між трьома кривими, наприклад вписане коло трикутника.", + "help.sketch.circle.two_point": "Малює коло за двома кінцями його діаметра. Використовуйте, коли відомі точки на краю кола, а не його центр.", + "help.sketch.circle.two_tangent": "Малює коло заданого розміру, що дотикається до двох прямих, кіл або дуг. Використовуйте для заокруглення сталого радіуса між двома кривими.", + "help.sketch.conic": "Малює конічну криву між двома кінцевими точками, форма якої задається вершинною точкою та значенням rho. Використовуйте для плавних переходів, повноту яких потрібно налаштувати, — від плоскої до майже гострого кута.", + "help.sketch.constrainer": "Застосовує обмеження, яке вибрані одна чи дві криві майже вже мають, як-от горизонтальність, паралельність, дотичність чи рівність. Використовуйте, щоб упорядкувати накреслений від руки ескіз по одному зв'язку за раз.", + "help.sketch.constraint.coincident": "Розміщує точку на іншій точці, кривій чи осі ескізу. Використовуйте, щоб закрити проміжки в профілі або розмістити точку на прямій.", + "help.sketch.constraint.collinear": "Розміщує дві прямі на одній прямій. Використовуйте, щоб вирівняти перервані краї, наприклад обидві сторони виїмки.", + "help.sketch.constraint.concentric": "Надає двом колам чи дугам спільний центр. Використовуйте для кілець, цековок і валів з уступами.", + "help.sketch.constraint.curvature": "Змушує дві криві, що сходяться в кінцевій точці, продовжуватися з однаковою кривизною (G2). Використовуйте там, де дотичного сполучення замало для плавності, наприклад на видимих поверхнях.", + "help.sketch.constraint.equal": "Робить прямі однакової довжини, а кола й дуги — однакового радіуса. Використовуйте, щоб один розмір керував кількома однаковими елементами.", + "help.sketch.constraint.fix": "Закріплює точки чи криві там, де вони є, або знову звільняє їх. Використовуйте для геометрії, що не повинна зміщуватися під час перетягування чи розв'язування ескізу.", + "help.sketch.constraint.horizontal_vertical": "Робить пряму або лінію між двома точками горизонтальною чи вертикальною — залежно від того, що ближче. Використовуйте, щоб вирівняти контур за осями ескізу.", + "help.sketch.constraint.midpoint": "Ставить точку на середину прямої або поєднує середини двох прямих. Використовуйте, щоб відцентрувати геометрію відносно ребра.", + "help.sketch.constraint.parallel": "Робить дві прямі паралельними. Використовуйте для сторін, які мають залишатися паралельними, коли змінюються інші розміри.", + "help.sketch.constraint.perpendicular": "Змушує дві прямі сходитися під прямим кутом. Використовуйте для прямих кутів між прямими, що не лежать на осях ескізу.", + "help.sketch.constraint.polygon": "Робить замкнений ланцюг прямих правильним багатокутником: рівні сторони, а кожен кут лежить на одному колі. Використовуйте, щоб зробити накреслений від руки багатокутник правильним і зберегти це.", + "help.sketch.constraint.symmetry": "Робить дві точки чи криві дзеркальним відображенням одна одної відносно прямої або точки. Використовуйте, щоб профіль залишався симетричним, коли ви змінюєте одну зі сторін.", + "help.sketch.constraint.tangent": "Змушує дві криві дотикатися по дотичній, наприклад пряму й дугу. Використовуйте для плавних сполучень без зламу.", + "help.sketch.construction": "Перемикає криві між звичайною та конструкційною геометрією, яку профілі не враховують. Використовуйте конструкційні криві як допоміжні для розмітки, орієнтирів і розмірів.", "help.sketch.construction_line": "Створює нескінченну допоміжну пряму через дві точки. Використовуйте її для розмітки ескізу без розбиття профілів.", "help.sketch.construction_line.angle": "Створює нескінченну допоміжну пряму під кутом до осі X ескізу. Виберіть точку й введіть кут або виберіть її напрямок.", "help.sketch.construction_line.bisector": "Виберіть дві прямі ескізу, що перетинаються, а потім одну з двох бісектрис. Результат змінюється разом із вихідними прямими.", @@ -1412,12 +1564,71 @@ "help.sketch.construction_line.tangent_external": "Виберіть коло або кругову дугу, потім зовнішню точку й варіант дотичної. У попередніх переглядах показано лише точки дотику на намальованій дузі.", "help.sketch.construction_line.tangent_two": "Виберіть два кола або кругові дуги, потім виберіть спільну дотичну в попередньому перегляді. Точки дотику мають лежати на намальованих кривих.", "help.sketch.construction_line.vertical": "Створює нескінченну вертикальну допоміжну пряму через точку. Її вертикальне обмеження зберігається під час подальших змін.", + "help.sketch.copy": "Копіює вибрані криві й точки ескізу разом з обмеженнями та розмірами між ними. Вставте їх у цей ескіз або в інший.", + "help.sketch.create": "Починає новий 2D-ескіз на площині або на плоскій грані моделі. Кожне видавлювання, обертання й протягування починається з ескізу.", + "help.sketch.cut": "Копіює вибрану геометрію ескізу й видаляє її з ескізу одним кроком скасування. Вставте її в іншому місці цього ескізу або в інший.", + "help.sketch.dimension": "Додає керівний розмір: довжину, відстань, кут, радіус чи діаметр залежно від вибраного. Введіть значення або назву параметра, і ескіз підлаштується.", + "help.sketch.edit": "Відкриває наявний ескіз для редагування. Операції, побудовані на ньому, оновлюються, коли ви завершуєте ескіз.", + "help.sketch.ellipse": "Малює еліпс за його центром, кінцем великої осі та точкою на малій стороні. Використовуйте для овальних отворів, кулачків та інших видовжених округлих форм.", "help.sketch.equation_curve": "Створює криву за параметричними рівняннями X(t), Y(t) у площині ескізу.", + "help.sketch.extend": "Подовжує пряму чи дугу до найближчої кривої на її шляху або нарощує дві криві, доки вони не зустрінуться. Використовуйте, щоб закрити проміжки в профілі після обрізання чи редагування.", + "help.sketch.fillet": "Заокруглює кут, де сходяться дві прямі чи дуги, дугою заданого радіуса. Кілька кутів, заокруглених разом, мають один радіус.", + "help.sketch.finish": "Виходить з ескізу й повертається до моделі. Його замкнені профілі стають готовими до «Видавлювання», «Обертання» й інших операцій.", + "help.sketch.finish_auto_constrain": "Виконує «Автообмеження» для ескізу, а потім завершує його. Використовуйте, щоб залишити ескіз повністю визначеним одним кроком.", + "help.sketch.fit_curves_to_section": "Розрізає тіло сітки чи суцільне тіло площиною ескізу й підганяє прямі та дуги до перерізу. Використовуйте, щоб відновити відскановану чи імпортовану деталь як ескіз, який можна редагувати.", + "help.sketch.include_3d": "Переносить ребра, вершини моделі й криві інших ескізів до цього ескізу як пов'язані посилання, сплющуючи ті, що поза його площиною, на неї. Використовуйте, щоб будувати на геометрії, яка не лежить у площині ескізу.", + "help.sketch.infinite": "Перемикає прямі між звичайними та нескінченними допоміжними прямими, що проходять через увесь вид. Використовуйте їх як орієнтири розмітки, які ніколи не виявляться закороткими.", + "help.sketch.insert_dxf": "Зчитує криві креслення DXF в ескіз, масштабовані, повернуті й розміщені так, як ви задасте. Використовуйте, щоб будувати на 2D-кресленні з іншої програми, наприклад на контурі для лазерного різання.", + "help.sketch.insert_svg": "Зчитує контури малюнка SVG в ескіз, масштабовані, повернуті й розміщені так, як ви задасте. Використовуйте, щоб перетворити логотип або векторний контур на криві, які можна видавити.", "help.sketch.inspect": "Відкриває звіт про відкритий ескіз: скільки ступенів вільності залишилося і чи він повністю обмежений. Використовуйте його, щоб дізнатися, що ще рухається, перш ніж завершити ескіз.", + "help.sketch.intersect": "Додає криві чи точки, де тіла, грані, ребра або інші ескізи перетинають площину ескізу. Вони залишаються пов'язаними й слідують за моделлю, коли вона змінюється.", + "help.sketch.intersection_curve": "Створює 3D-криві там, де сходяться два тіла чи грані, пов'язані з ними. Використовуйте їх як траєкторії або напрямні, що слідують за моделлю.", + "help.sketch.isoparametric_curve": "Створює 3D-криву, пов'язану з гранню, вздовж напрямку U або V грані через вибрану вами точку. Використовуйте як траєкторію або лінію розділення, що слідує за поверхнею.", + "help.sketch.line": "Малює з'єднані прямі точка за точкою — як розімкнений ланцюг або замкнений контур. Це основний інструмент для більшості профілів.", + "help.sketch.line.midpoint": "Малює пряму від її середини до одного кінця, а другий кінець відображається дзеркально. Використовуйте для прямих, центрованих на точці, наприклад симетричного краю.", + "help.sketch.line3d": "Малює з'єднані прямі між точками в просторі за ввімкненого 3D-ескізу. Використовуйте для траєкторій і рам, що виходять із площини ескізу.", + "help.sketch.mirror": "Віддзеркалює криві й точки ескізу відносно прямої або осі та додає обмеження симетрії. Використовуйте, щоб накреслити половину симетричного профілю й доповнити його.", + "help.sketch.move": "Переміщує або повертає криві й точки ескізу чи їхню копію. Використовуйте, щоб змінити положення геометрії, зберігши її форму й обмеження.", + "help.sketch.offset": "Створює копію ланцюга кривих на заданій відстані з одного чи обох боків. Використовуйте для стінок, контурів і зазорів; відстань залишається розміром, який можна редагувати.", + "help.sketch.paste": "Вставляє в активний ескіз геометрію ескізу, яку ви скопіювали чи вирізали. Розмістіть її в точці або зсуньте на відстань.", + "help.sketch.pattern.circular": "Копіює геометрію ескізу навколо центральної точки по повному колу або на заданий кут. Використовуйте для кіл розташування болтів, спиць і зубів.", + "help.sketch.pattern.rectangular": "Копіює геометрію ескізу рядками й стовпцями із заданими проміжками. Використовуйте для сіток отворів, пазів чи вентиляційних прорізів.", + "help.sketch.point": "Ставить точку ескізу. Використовуйте як орієнтир для розмірів, обмежень, центрів отворів або пізніших операцій.", + "help.sketch.point3d": "Ставить точку в просторі за ввімкненого 3D-ескізу. Використовуйте як орієнтир для 3D-кривих і траєкторій.", + "help.sketch.polygon.circumscribed": "Малює правильний багатокутник навколо кола, розмір якого задано від центра до середини сторони. Використовуйте, коли відомий розмір під ключ, як для шестигранної гайки.", + "help.sketch.polygon.edge": "Малює правильний багатокутник за двома кінцями однієї сторони. Використовуйте, коли відомі довжина й положення сторони.", + "help.sketch.polygon.inscribed": "Малює правильний багатокутник усередині кола, розмір якого задано від центра до кута. Використовуйте, коли відомий розмір між протилежними кутами.", + "help.sketch.project": "Переносить ребра, грані, вершини моделі та криві інших ескізів до цього ескізу, спроєктовані на його площину. Вони залишаються пов'язаними й слідують за моделлю, коли вона змінюється.", + "help.sketch.project_to_surface": "Проєктує криві інших ескізів уздовж нормалі цього ескізу на грань як пов'язані 3D-криві. Використовуйте, щоб нанести логотип, лінію розділення чи траєкторію на криву поверхню.", + "help.sketch.rectangle.center": "Малює прямокутник за центром і одним кутом. Використовуйте для контурів, центрованих на точці, наприклад на початку координат.", + "help.sketch.rectangle.rounded": "Малює прямокутник із чотирма кутами, заокругленими одним радіусом. Використовуйте для пластин, кришок і отворів із заокругленими кутами.", + "help.sketch.rectangle.three_point": "Малює прямокутник за одним краєм і точкою на протилежній стороні. Використовуйте для прямокутників, повернутих під кутом до осей ескізу.", + "help.sketch.rectangle.two_point": "Малює прямокутник за двома протилежними кутами. Використовуйте для контурів пластин, блоків і кишень.", + "help.sketch.scale": "Масштабує криві й точки ескізу на коефіцієнт відносно базової точки. Використовуйте, щоб змінити розмір частини ескізу, не креслячи її знову.", + "help.sketch.slot.arc_center": "Малює дуговий паз заданої ширини вздовж дуги, заданої центром, початком і кінцем. Використовуйте для регулювальних пазів навколо осі повороту.", + "help.sketch.slot.arc_three_point": "Малює дуговий паз заданої ширини вздовж дуги через три точки. Використовуйте, коли траєкторія паза має проходити через задані точки.", + "help.sketch.slot.center_point": "Малює прямий паз заданої ширини від його центра до центра однієї кінцевої дуги. Використовуйте для пазів, центрованих на точці.", + "help.sketch.slot.center_to_center": "Малює прямий паз заданої ширини між центрами двох його кінцевих дуг. Використовуйте для пазів, розмір яких задано від центру до центру, наприклад регулювальних пазів для гвинтів.", + "help.sketch.slot.overall": "Малює прямий паз заданої ширини від одного зовнішнього кінця до іншого. Використовуйте, коли важлива загальна довжина паза.", + "help.sketch.spline.control_point": "Малює кубічний сплайн, форма якого задається контрольними точками, крізь які він не проходить. Використовуйте для плавних, текучих кривих, які ви формуєте переміщенням точок.", + "help.sketch.spline.control_point_5": "Малює сплайн 5-го степеня, форма якого задається контрольними точками. Використовуйте там, де криві мають сполучатися з плавнішою кривизною, наприклад на видимих дизайнерських поверхнях.", + "help.sketch.spline.fit_point": "Малює плавний сплайн, що проходить через кожну розміщену вами точку, розімкнений чи замкнений. Використовуйте, щоб обвести форму через відомі точки.", + "help.sketch.spline3d": "Малює плавну криву в просторі через точки за ввімкненого 3D-ескізу. Використовуйте для траєкторій протягування та прокладання кабелів, що виходять із площини ескізу.", + "help.sketch.spun_profile": "Створює в площині ескізу контур, який тіло описує, обертаючись навколо осі. Використовуйте, щоб отримати силует точеної деталі для обертання чи креслення.", + "help.sketch.text": "Пише текст в ескізі вибраним шрифтом, висотою та під вибраним кутом; його слова можуть залежати від параметрів. Видавіть його або витисніть для написів і підписів.", + "help.sketch.transform": "Масштабує, повертає й переміщує геометрію ескізу відносно центральної точки одним кроком. Використовуйте, щоб одразу змінити положення й розмір виділеного.", + "help.sketch.trim": "Видаляє частину кривої між найближчими перетинами навколо місця, де ви клацнули. Використовуйте, щоб очистити лінії, що накладаються, до замкненого профілю.", "help.solid.align": "Переміщує вибрані тіла так, щоб точка, грань чи площина на них зійшлася з точкою, гранню чи площиною деінде. Використовуйте, щоб притиснути деталь до іншої, не вимірюючи зсуву.", + "help.solid.boundary_fill": "Створює суцільні тіла з областей, обмежених тілами-інструментами, що перекриваються, заповнюючи лише вибрані області. Використовуйте, щоб побудувати форму з простору, який разом обмежують кілька тіл.", + "help.solid.bounding_solid": "Створює прямокутний паралелепіпед, що охоплює вибрані тіла, за потреби із запасом. Використовуйте, щоб побачити габарити деталі або як заготовку, з якої її обробляють.", + "help.solid.box": "Створює куб (паралелепіпед) за довжиною, шириною й висотою від кутової точки або з центром у точці. Використовуйте для швидкого блока, заготовки-заповнювача чи тіла, у якому вирізають елементи.", "help.solid.chamfer": "Зрізає вибрані ребра плоским скосом на заданій відстані. Використовуйте, щоб притупити гострі ребра або полегшити вставляння штифта чи гвинта.", + "help.solid.coil": "Створює гвинтове тіло, наприклад пружину, за діаметром, кількістю витків, висотою чи кроком і круглим або квадратним перерізом. Використовуйте для пружин, спіралей і гвинтових гребенів.", "help.solid.combine": "Об'єднує тіла, вирізає їх із цільового тіла або залишає лише спільний для них об'єм, і може зберегти тіла-інструменти. Використовуйте, щоб з'єднати деталі, вирізати одну форму іншою або знайти, де два тіла перекриваються.", + "help.solid.cone": "Створює конус за колом основи та висотою або усічений конус, коли верх теж має радіус. Використовуйте для конічних звужень, лійок, зенківок і вістер.", + "help.solid.cylinder": "Створює циліндр за радіусом чи діаметром і висотою, що стоїть на точці вздовж осі. Використовуйте для валів, штифтів, бобишок і круглих заготовок.", "help.solid.draft": "Нахиляє вибрані грані на заданий кут відносно нейтральної площини. Використовуйте для литих чи формованих деталей, щоб вони виймалися з форми.", + "help.solid.emboss": "Піднімає профілі ескізу, наприклад текст чи логотип, над гранню або заглиблює їх у неї. Використовуйте для написів, етикеток і маркування на плоских чи округлих гранях.", "help.solid.extrude": "Витягує профіль ескізу в 3D на задану відстань, додаючи або видаляючи матеріал. Використовуйте, щоб перетворити плоский контур на брусок, бобишку або кишеню.", "help.solid.fillet": "Закруглює вибрані ребра плавним круговим переходом заданого радіуса. Використовуйте, щоб пом'якшити гострі ребра для міцності, зручності чи вигляду.", "help.solid.hole": "Просвердлює круглий отвір заданого діаметра в обраній точці на грані. Використовуйте для отворів під гвинти, штифтів та інших простих круглих отворів.", @@ -1426,20 +1637,60 @@ "help.solid.move": "Переміщує або обертає вибрані тіла (чи їхню копію) на задану відстань і кут. Використовуйте, щоб змінити положення тіла або створити друге в іншому місці.", "help.solid.offset_face": "Виштовхує або втягує вибрані грані на задану відстань, а навколишні грані слідують за ними. Використовуйте, щоб зробити стінку товщою, кишеню глибшою, а бобишку вищою без редагування ескізу.", "help.solid.pattern.circular": "Повторює вибрані об'єкти навколо осі задану кількість разів на заданий повний кут. Використовуйте для кіл розташування болтів, спиць, оребрення та зубців.", + "help.solid.pattern.path": "Копіює операції чи тіла вздовж траєкторії ескізу за кількістю та кроком. Використовуйте для отворів уздовж криволінійного ребра чи деталей уздовж напрямної.", "help.solid.pattern.rectangular": "Повторює вибрані об'єкти в одному або двох напрямках із заданими кількістю та кроком. Використовуйте для рядів і сіток отворів, ребер жорсткості, штифтів або ніжок.", + "help.solid.pipe": "Створює круглу трубу вздовж траєкторії з кривих ескізу, суцільну чи порожнисту із заданою товщиною стінки. Використовуйте для труб, дротів, кабелів і напрямних.", "help.solid.press_pull": "Додає або видаляє матеріал, коли ви тягнете грань, або заокруглює ребра, коли тягнете їх. Використовуйте для швидких змін просто на моделі.", "help.solid.remove": "Забирає вибрані тіла з проєкту, починаючи з цієї точки часового ланцюжка; попередні кроки їх зберігають. Використовуйте, щоб відкинути допоміжне тіло, наприклад інструмент різання, який уже не потрібен.", "help.solid.replace_face": "Переносить вибрані плоскі грані на площину або на паралельну грань, і тіло зростає чи зменшується до неї. Використовуйте, щоб вирівняти грань урівень з іншою деталлю або допоміжною площиною.", "help.solid.revolve": "Обертає профіль ескізу навколо осі, утворюючи кругле тіло. Використовуйте для валів, ручок, чаш та будь-яких деталей, круглих навколо осьової лінії.", + "help.solid.rib": "Додає тонку стінку вздовж розімкненої кривої ескізу, що сягає до найближчих граней тіла. Використовуйте, щоб зміцнити стінки й кути, наприклад у формованих чи надрукованих деталях.", "help.solid.scale": "Збільшує або зменшує вибрані тіла на коефіцієнт: однаковий у всіх напрямках або окремий для кожної осі. Використовуйте, щоб змінити розмір імпортованої форми або врахувати усадку.", "help.solid.shell": "Робить тіло порожнистим, залишаючи стінки заданої товщини, і може відкривати вибрані грані. Використовуйте для коробок, кришок та інших тонкостінних деталей.", + "help.solid.sphere": "Створює кулю за радіусом чи діаметром навколо центральної точки. Використовуйте для куль, ручок і заокруглених кінців.", "help.solid.split_body": "Розрізає тіло на окремі тіла площиною, гранню іншого тіла або лінією ескізу. Використовуйте, щоб отримати деталі, які друкуються, обробляються чи фарбуються окремо.", "help.solid.split_face": "Ділить вибрані грані за площиною, гранню іншого тіла або кривими ескізу, не розрізаючи тіла. Використовуйте, щоб надати частині грані власний ухил, колір чи зсув.", "help.solid.sweep": "Протягує профіль ескізу вздовж траєкторії, утворюючи суцільне тіло. Використовуйте для труб, напрямних, ручок і будь-якої деталі зі сталим перерізом, що йде за кривою.", + "help.solid.thread": "Додає метричну різьбу ISO на циліндричну грань — або косметичну, або вирізану в тілі. Використовуйте для болтів, різьбових отворів і надрукованих гвинтових деталей.", + "help.solid.torus": "Створює кільце за великим радіусом навколо його осі та малим радіусом його круглого перерізу. Використовуйте для O-подібних кілець, ручок і круглих намистин.", + "help.solid.web": "Додає тонку стінку вздовж кожної розімкненої кривої ескізу, кожна з яких сягає до найближчих граней тіла. Використовуйте для сітки опорних стінок усередині корпусів і лотків.", + "help.surface.blend": "Створює поверхню, що з'єднує ребра двох тіл, дотичну або неперервну за кривизною до обох. Використовуйте, щоб плавно з'єднати дві поверхні через проміжок.", + "help.surface.boundary": "Створює поверхню, що заповнює контур із двох-чотирьох кривих ескізу або сітку кривих. Використовуйте для вільних панелей, заданих своїми краями.", + "help.surface.extend": "Подовжує поверхню за її краї на відстань або до площини чи грані. Використовуйте, щоб поверхні перекривалися перед обрізанням чи зшиванням.", + "help.surface.extrude": "Видавлює криві чи профілі ескізу на відстань у поверхню — лише стінки, без торців. Використовуйте для тонких стінок і для інструментів різання чи розділення.", + "help.surface.fair": "Згладжує внутрішню частину вільних граней, залишаючи їхні краї на місці. Використовуйте, щоб вирівняти горби й хвилясті відбиття на поверхні.", + "help.surface.loft": "Створює поверхню, що послідовно проходить через два чи більше перерізів, без торців. Використовуйте для оболонок і переходів між різними формами.", + "help.surface.match": "Перебудовує край поверхні так, щоб він сходився з краєм іншого тіла з відповідною дотичністю чи кривизною. Використовуйте, щоб плавно закрити шов між двома поверхнями.", + "help.surface.offset": "Створює копію граней на відстані вздовж їхніх нормалей як нове поверхневе тіло. Використовуйте для оболонок-шкірок, зазорів або іншого боку стінки.", + "help.surface.patch": "Заповнює область ескізу чи замкнений контур ребер гранню, плоскою або плавно вигнутою. Використовуйте, щоб закрити отвори в поверхневому тілі.", + "help.surface.reverse_normal": "Перевертає грані поверхневого тіла, тож їхній зовнішній бік дивиться в інший бік. Використовуйте перед потовщенням чи зшиванням, коли грані спрямовані всередину.", + "help.surface.revolve": "Обертає криві чи профілі ескізу навколо осі в поверхню, без торців. Використовуйте для круглих оболонок, як-от абажури, чаші й сопла.", + "help.surface.ruled": "Створює смугу з прямих ліній від ланцюга ребер уздовж нормалі грані, дотичної або під кутом. Використовуйте для стінок з ухилом, виходів і поверхонь рознімання.", + "help.surface.stitch": "Зшиває поверхневі тіла разом уздовж спільних ребер, утворюючи суцільне тіло, коли вони замикаються. Використовуйте, щоб перетворити набір поверхонь на одне тіло.", + "help.surface.sweep": "Переміщує криві чи профілі ескізу вздовж траєкторії в поверхню, без торців. Використовуйте для ринв, обкладок та інших тонких форм, що йдуть уздовж траєкторії.", + "help.surface.thicken": "Надає поверхневому тілу товщину, перетворюючи його на суцільне. Використовуйте, щоб створювати з поверхонь деталі для друку чи механічної обробки.", + "help.surface.trim": "Розрізає поверхню площиною, іншою поверхнею чи кривими ескізу й залишає вибраний бік. Використовуйте, щоб надати поверхням форми перед зшиванням.", + "help.surface.unstitch": "Розділяє грані тіла на окремі поверхневі тіла; суцільне тіло розпадається на поверхні. Використовуйте, щоб переробляти окремі грані тіла.", + "help.surface.untrim": "Прибирає отвори з грані або повертає її зовнішню межу до природних меж поверхні. Використовуйте, щоб повернути повну поверхню після обрізання.", "help.third_party": "Ліцензії сторонніх компонентів", "help.timeline.compute_all": "Перебудовує кожну операцію часового ланцюжка з початку. Використовуйте, щоб оновити модель і побачити, які операції завершуються помилкою.", "help.timeline.delete": "Видаляє вибрані операції з часового ланцюжка; видалення ескізу видаляє й операції, побудовані на ньому. Використовуйте, щоб прибрати крок, який більше не потрібен.", + "help.timeline.dependents": "Перелічує операції, які буде видалено разом з операцією, і ті, що перестануть працювати без неї. Використовуйте перед видаленням чи зміною операції.", "help.timeline.play": "Відтворює решту операцій по одній, із паузою в півсекунди після кожного перебудування. Виконайте цю команду ще раз або натисніть Esc, щоб зупинити; «Перейти в кінець» переходить одразу.", + "help.view.apply": "Повертає вид до стандартного напрямку, як-от спереду, зверху чи ізометрія, або до збереженого іменованого виду. Використовуйте, щоб дивитися на модель із фіксованого кута.", + "help.view.delete": "Видаляє збережений іменований вид. Модель не змінюється.", + "help.view.fit": "Масштабує й зсуває вид так, щоб уся модель або виділене заповнювала його. Використовуйте, коли модель зникла з поля зору.", + "help.view.grid": "Показує чи ховає сітку у вікні перегляду. Використовуйте як орієнтир розмірів і напрямків під час моделювання.", + "help.view.ground_shadow": "Показує чи ховає тінь моделі на землі. Використовуйте, щоб легше оцінювати висоту й положення.", + "help.view.home": "Повертає вид до головного напрямку й вміщує модель. Використовуйте, щоб швидко повернутися до відомого виду.", + "help.view.list": "Перелічує іменовані види, збережені в проєкті. Використовуйте, щоб знайти вид, який ви зберегли раніше.", + "help.view.navigate": "Повертає, панорамує чи масштабує вид кроками, наприклад на чверть оберту ліворуч. Використовуйте, щоб точно переміщувати камеру замість перетягування.", + "help.view.navigation_bar": "Показує чи ховає панель навігації внизу вікна перегляду. Сховайте її, щоб звільнити місце, або покажіть, щоб мати доступ до інструментів виду мишею.", + "help.view.rename": "Дає збереженому іменованому виду нову назву. Сам вид не змінюється.", + "help.view.save": "Зберігає поточне положення камери як іменований вид у проєкті. Використовуйте, щоб пізніше повернутися до того самого кута, наприклад для знімків екрана.", + "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": "Підтвердити видалення", @@ -1772,6 +2023,11 @@ "not a move": "це не переміщення", "not a number": "не число", "open a sketch first": "спершу відкрийте ескіз", + "parameters.apply_live": "Застосовувати кожну зміну, коли ви залишаєте її поле", + "parameters.apply_live_tip": "Вимкнено: зміни чекають на «Застосувати» або OK, після чого проєкт один раз перебудовується для всіх. «Скасувати» чи Esc відкидає їх.", + "parameters.held": "Змін, що чекають на «Застосувати»: {count}", + "parameters.held_hint": "Змінюйте вирази та одиниці. Зміни чекають на «Застосувати» або OK.", + "parameters.held_one": "1 зміна чекає на «Застосувати»", "parts.automatic_name": "автоматично", "parts.dimension.across_flats": "Розмір під ключ", "parts.dimension.bore": "Діаметр отвору", @@ -1962,6 +2218,8 @@ "prefs.origin_plane_names": "Назви площин початку координат", "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.persistent_ribbon_pressed": "Підсвічувати активні команди стрічки", "prefs.persistent_ribbon_pressed_help": "Підсвічувати команди стрічки, поки їхні ефекти ввімкнені. Вимкніть, щоб зберегти попередній нейтральний вигляд і довідку команд.", "prefs.perspective_fov": "Кут огляду перспективи", @@ -2038,6 +2296,8 @@ "prefs.sketch_tab_always_note": "Вкладка «Ескіз» залишається на стрічці одразу після вкладки «Модель», поки жоден ескіз не відкрито. Її інструменти креслення тоді запускають ескіз на вибраній площині чи грані або на площині, яку ви виберете далі, з уже готовим інструментом.", "prefs.sketch_tracking_guides": "Допоміжні лінії вирівнювання за центром і середньою точкою", "prefs.sketch_tracking_guides_note": "Наведіть курсор на центр кола або середню точку прямого відрізка, потім рухайте курсор горизонтально чи вертикально, щоб показати тимчасову пунктирну допоміжну лінію.", + "prefs.sketch_tracking_points": "Допоміжні лінії вирівнювання від кінцевих точок і кутів під час креслення", + "prefs.sketch_tracking_points_note": "Поки інструмент креслення очікує точку, наведення курсора на кінець прямої чи дуги, кут прямокутника або вільну точку показує таку саму тимчасову пунктирну допоміжну лінію, як і для центра чи середньої точки. Вимкнено: лише центри та середні точки.", "prefs.sketching": "Ескізування", "prefs.snap_drawing_note": "Під час малювання або перетягування точки ескізу.", "prefs.source": "Джерело", @@ -2065,6 +2325,16 @@ "prefs.theme_follow_off": "Вимк.", "prefs.theme_follow_stop": "Припинити відстеження", "prefs.theme_import_hint": "Клацніть, щоб вибрати файл теми, перетягніть його сюди або натисніть Ctrl+V (Cmd+V на Mac) зі скопійованою темою", + "prefs.theme_option_opacity": "Непрозорість", + "prefs.theme_option_opacity_help": "Налаштуйте непрозорість плаваючих панелей. Нижня межа читабельності захищає текст на кольорах вікна перегляду.", + "prefs.theme_option_tab_colours": "Кольори вкладок", + "prefs.theme_option_tab_colours_help": "Використовувати кольори панелей і вікна перегляду цієї теми для кожної вкладки стрічки.", + "prefs.theme_option_tab_fade_ms": "Перехід між вкладками", + "prefs.theme_option_tab_fade_ms_help": "Плавно змінювати кольори панелей і вікна перегляду під час перемикання вкладок. Нуль перемикає миттєво.", + "prefs.theme_options_badge": "Параметри", + "prefs.theme_options_more": "Ще · {count}", + "prefs.theme_options_reset": "Скинути", + "prefs.theme_options_unavailable": "Недоступно на цьому пристрої", "prefs.theme_paste": "Вставити тему", "prefs.theme_paste_none": "У буфері обміну немає теми SolidCraft", "prefs.timeline_half_hour": "−30 хв", @@ -2086,6 +2356,8 @@ "prefs.under_cursor": "Під курсором", "prefs.units_precision": "Одиниці й точність", "prefs.updates": "Оновлення", + "prefs.value_boxes_in_view": "Показувати значення біля їхніх маркерів на виді", + "prefs.value_boxes_in_view_tip": "Значення команди (Відстань, Радіус…) у полі біля її маркера, поверх моделі. Вимкнено: ви вводите його у вікні команди, яке після вибору отримує клавіатуру, і ніщо не закриває модель.", "prefs.view": "Поведінка виду", "prefs.view_confirm_corner": "OK і Скасувати в куті виду", "prefs.view_confirm_corner_note": "Поки відкрито діалог або ескіз, галочка й хрестик у правому верхньому куті виду роблять те саме, що OK і Скасувати в діалозі, або завершують і скасовують ескіз. Навігатор виду зсувається нижче.", @@ -2097,6 +2369,10 @@ "prefs.viewport_background_theme": "Градієнт теми", "prefs.viewport_section_status": "Показувати стан перерізу у вікні перегляду", "prefs.viewport_section_status_help": "Показувати налаштування перерізу у вікні перегляду. Escape і активна кнопка стрічки вимикають його. Вимкніть, щоб зберегти попередні елементи керування перерізом.", + "prefs.viewport_status_details": "Інтерактивні подробиці вікна перегляду", + "prefs.viewport_status_details_help": "Редагуйте налаштування активного аналізу та перерізу в стандартних картках подробиць. Вимкніть, щоб повернути легенди аналізу лише для читання та діалог «Переріз».", + "prefs.viewport_status_summary": "Згортати елементи рядка стану", + "prefs.viewport_status_summary_help": "Згортати три й більше елементів рядка стану в один зведений значок. Вимкніть, щоб залишити всі елементи в повному рядку.", "prefs.wheel": "Колесо", "prefs.wheel_trackpad": "Колесо й трекпад", "prefs.workspace": "Робоча область", @@ -2975,7 +3251,9 @@ "render.output_ready": "Готово до рендерингу", "render.output_run_check": "Запустити перевірку", "render.output_size_limit": "{method}: до {dimension} px на сторону та {pixels} пікселів. Найбільший придатний розмір: {width} × {height}.", + "render.output_size_method_profile": "Щоб візуалізувати {width} × {height}, виберіть {method} із профілем ресурсів CPU {profile} або менший розмір.", "render.output_size_ok": "Розмір допустимий · {width} × {height} px", + "render.output_size_profile": "Щоб візуалізувати {width} × {height}, виберіть профіль ресурсів CPU {profile} або менший розмір.", "render.output_swap_orientation": "Поміняти місцями ширину та висоту", "render.output_tools_inspector": "Змініть розмір і формат в інспекторі «Вивід».", "render.output_view_image": "Переглянути зображення", @@ -3188,6 +3466,7 @@ "render.size_custom": "Власний…", "render.size_full_hd": "Full HD", "render.size_hd": "HD", + "render.size_needs_profile": "{size} · потрібен {profile}", "render.size_presets": "Набори розмірів", "render.size_presets_pending": "З'явиться разом із редактором виведення", "render.size_preview": "Попередній перегляд", @@ -3475,6 +3754,24 @@ "sheet.flange_type_contour": "Контур", "sheet.flange_type_edge": "Ребро", "sheet.no_body": "немає листового тіла: створіть його через Фланець > Основа або перетворіть пластину на листовий метал командою «Перетворити на листовий метал» (виберіть її велику плоску грань)", + "sheet.personal_rules.confirm_delete": "Підтвердити видалення", + "sheet.personal_rules.delete": "Видалити", + "sheet.personal_rules.delete_prompt": "Видалити це особисте правило? Правила, що вже є в проєктах, не зміняться.", + "sheet.personal_rules.full": "Особиста бібліотека вже містить 64 правила.", + "sheet.personal_rules.invalid_name": "Введіть коротку назву без розривів рядка та керівних символів.", + "sheet.personal_rules.invalid_values": "Заповніть значення правила: кожне має обчислюватися в цьому проєкті, з додатною товщиною та коефіцієнтом K від 0 до 1.", + "sheet.personal_rules.missing": "Вибране особисте правило більше недоступне.", + "sheet.personal_rules.name": "Назва правила", + "sheet.personal_rules.name_exists": "Особисте правило з такою назвою вже існує. Використайте іншу назву.", + "sheet.personal_rules.name_hint": "Унікальна назва", + "sheet.personal_rules.overwrite": "Зберегти зміни у вибраному особистому правилі", + "sheet.personal_rules.rename": "Перейменувати", + "sheet.personal_rules.rule": "Особисте правило", + "sheet.personal_rules.save": "Зберегти як нове", + "sheet.personal_rules.scope": "Зберігається у вашому профілі для всіх проєктів.", + "sheet.personal_rules.select": "Виберіть правило", + "sheet.personal_rules.title": "Особисті правила", + "sheet.personal_rules.unsupported": "Це правило має значення, які ця версія не може використати.", "shortcut.json_error": "параметри мають бути у форматі JSON: {error}", "shortcut.no_command": "команду `{id}` не знайдено; текст перед JSON має бути ID команди", "shortcut.pin": "Закріпити вгорі поля S", @@ -3511,6 +3808,7 @@ "sketch_text.align_right": "Праворуч", "sketch_text.bold": "Жирний", "sketch_text.builtin_font": "{family} (вбудований)", + "sketch_text.formula": "Почніть із =, щоб скласти слова з параметрів: ='W' + number(Width)", "sketch_text.italic": "Курсив", "sketch_text.loading_fonts": "Пошук встановлених шрифтів…", "sketch_text.on_path": "Текст дотримується своєї траєкторії: крива його розміщує", @@ -3651,6 +3949,8 @@ "ui.3d_mouse_section": "3D-миша", "ui.3d_mouse_show_in_view": "Показувати 3D-мишу в 3D-вигляді", "ui.3d_mouse_show_in_view_tip": "Зображення вашої 3D-миші в лівому нижньому куті, що рухається разом із нею.", + "ui.3d_mouse_sketch_orbit": "Повертати ескізи 3D-мишею", + "ui.3d_mouse_sketch_orbit_tip": "3D-миша також повертає вид, поки ескіз відкрито й ви дивитеся на нього прямо, так само, як і звичайна миша. Вимкнено: вона панорамує та масштабує ескіз, доки ви не нахилите вид.", "ui.3d_mouse_skip": "Мій пристрій так не вміє", "ui.3d_mouse_skip_tip": "Пропускає цей рух в обидва боки; калібрування його не враховує.", "ui.3d_mouse_speed": "Швидкість 3D-миші", @@ -3719,6 +4019,7 @@ "ui.agent_chat_auth_terminal": "Виконайте це в терміналі, а потім знову натисніть «Під'єднатися»:", "ui.agent_chat_auth_title": "Вхід до агента", "ui.agent_chat_cancel": "Скасувати", + "ui.agent_chat_cannot_start": "Встановлено, але не запускається: {error}", "ui.agent_chat_changes": "Зміни: {n}, один крок скасування", "ui.agent_chat_changes_n": "Змін: {n}", "ui.agent_chat_changes_of": "{n}/{limit} змін", @@ -3811,9 +4112,12 @@ "ui.agent_chat_queued_stopped": "У черзі (зупинено, не надіслано)", "ui.agent_chat_refresh": "Оновити", "ui.agent_chat_refused": "Не дозволено для агента: {reason}", + "ui.agent_chat_reinstall": "{agent} встановлено ({program}), але він не запустився, коли SolidCraft його перевіряв: {error}\nЙого встановлення могло бути неповним або зробленим для іншого типу комп'ютера. Встановіть його знову командою `{install}`, перевірте в терміналі, що він виводить свою версію, а потім натисніть «Оновити».", "ui.agent_chat_remove": "Прибрати з черги", "ui.agent_chat_remove_file": "Прибрати з цього повідомлення", "ui.agent_chat_remove_imported": "Прибрати з цього повідомлення; деталь залишається в проєкті (скасування її прибирає)", + "ui.agent_chat_reply_cut_off": "Відповідь досягла обмеження довжини, не закінчившись, тож вона може бути неповною. Попросіть агента продовжити або дати коротшу відповідь.", + "ui.agent_chat_reply_declined": "Модель відмовилася відповідати на це повідомлення.", "ui.agent_chat_retry": "Повторити", "ui.agent_chat_right": "Праворуч", "ui.agent_chat_save": "Зберегти", @@ -3872,12 +4176,17 @@ "ui.ai_copy_suffix": "копія", "ui.ai_custom_url": "Власна URL-адреса", "ui.ai_default": "Типовий", + "ui.ai_edit": "Редагувати", + "ui.ai_edit_agent_title": "Редагувати агента", + "ui.ai_edit_gone": "Цього агента видалено, тож зберігати нічого.", + "ui.ai_edit_tip": "Змініть назву, команду або кінцеву точку цього агента. Збережений ключ залишається прихованим.", "ui.ai_http_busy": "кінцева точка зайнята або перевищила ліміт; спробуйте пізніше", "ui.ai_http_key": "кінцева точка відхилила ключ: перевірте його в Налаштування > ШІ", "ui.ai_http_not_found": "немає такої кінцевої точки чи моделі: перевірте базову URL-адресу й назву моделі", "ui.ai_http_too_large": "розмова завелика для цієї кінцевої точки; почніть новий чат", "ui.ai_key": "Ключ API", "ui.ai_key_hint": "необов'язково; зберігається в системному сховищі ключів", + "ui.ai_key_keep_hint": "залиште порожнім, щоб зберегти наявний ключ", "ui.ai_key_needs_https": "Ключ надсилається лише через HTTPS або на цей комп'ютер.", "ui.ai_key_note": "Зберігається в системному сховищі ключів, ніколи в налаштуваннях SolidCraft. Залиште порожнім для локального сервера.", "ui.ai_keychain_error": "Системне сховище ключів відмовило: {error}", @@ -3909,6 +4218,7 @@ "ui.ai_request_timeout": "Тайм-аут запиту", "ui.ai_request_timeout_hint": "Скільки може тривати один запит до моделі через цю кінцеву точку: від 30 с до години. Збільште для повільної локальної моделі.", "ui.ai_reset": "Скинути", + "ui.ai_save": "Зберегти", "ui.ai_settings": "Налаштування ШІ…", "ui.ai_show_features": "Показувати функції ШІ", "ui.ai_show_features_note": "Чат з агентом: панель, де агент для програмування, яким ви вже користуєтеся, або обрана вами модельна кінцева точка працює з відкритим проєктом. Вимкнено: немає кнопки ШІ, комбінації клавіш, пункту меню чи панелі, і нічого не шукається та не запускається.", @@ -4082,6 +4392,40 @@ "ui.design_properties.project": "Проєкт", "ui.design_properties.revision": "Редакція", "ui.design_saved": "Збережено в {path}", + "ui.details_access_caption": "Грані показують, чи може інструмент дістатися до них із вибраного напрямку.", + "ui.details_angle": "Кут", + "ui.details_continuity_caption": "Редагуйте вибраний шов. Інші шви зберігають власні налаштування.", + "ui.details_continuity_stale": "Попередня геометрія: повторіть аналіз, щоб редагувати цей шов або показати його в кадрі.", + "ui.details_copy_list": "Копіювати список", + "ui.details_curvature": "Допуск кривизни", + "ui.details_curvature_caption": "Грані розфарбовано за їхнім локальним радіусом кривизни.", + "ui.details_direction": "Напрямок інструмента", + "ui.details_draft_caption": "Грані розфарбовано за їхнім кутом до напрямку витягування.", + "ui.details_edit_seam": "Редагувати шов", + "ui.details_fixture": "Зразок компонента — лише приклад даних", + "ui.details_fixture_caption": "Зразок компонента з ілюстративними значеннями; це не результат для моделі.", + "ui.details_fixture_problem": "Приклад результату з проблемою", + "ui.details_fixture_readout": "Приклад виміряного значення", + "ui.details_flip": "Перевернути", + "ui.details_gap": "Проміжок", + "ui.details_hatch": "Штрихувати перерізані грані", + "ui.details_jump": "Стрибок кривизни", + "ui.details_key": "Легенда", + "ui.details_offset": "Зсув", + "ui.details_plane": "Площина", + "ui.details_pull": "Напрямок витягування", + "ui.details_radius": "Радіус", + "ui.details_readout": "Показання", + "ui.details_samples": "Вибірки", + "ui.details_seam": "{body} · Ребро {edge}", + "ui.details_settings": "Налаштування", + "ui.details_sharp_gap": "Злам або проміжок", + "ui.details_show_all": "Показати всі: {count}", + "ui.details_smooth": "Плавний", + "ui.details_stripes": "Смуги", + "ui.details_target": "Вмістити геометрію", + "ui.details_worst_seams": "Найгірші шви", + "ui.details_zebra_caption": "Відбиті смуги показують, наскільки гладко сполучаються грані.", "ui.diameter": "Діаметр", "ui.dimension": "Розмір", "ui.direct_interaction": "Пряма взаємодія", @@ -4142,6 +4486,7 @@ "ui.equation_y_of_x": "Y(x)", "ui.equivalent_radius": "Еквівалентний радіус", "ui.expand": "Розгорнути", + "ui.explode_needs_two_components": "«Рознесений вид» розсовує компоненти, тож у проєкті має бути два чи більше з них («Новий компонент», «Вставити проєкт» або «Створити компоненти з тіл»)", "ui.exportDxf": "Експорт DXF…", "ui.export_ellipsis_keymap": "Експорт…", "ui.export_keymap_tip": "Клавіші, обрані в полі S і панель інструментів як файл", @@ -4469,6 +4814,7 @@ "ui.properties.inertia_y": "Iyx, Iyy, Iyz", "ui.properties.inertia_z": "Izx, Izy, Izz", "ui.properties.measuring": "Вимірювання…", + "ui.properties.measuring_bodies": "Вимірювання… {done} з {total} тіл", "ui.properties.measuring_failed": "Вимірювання перервано до завершення.", "ui.properties.principal_axis": "Головна вісь {number}", "ui.properties.principal_moments": "Головні моменти", @@ -4536,6 +4882,7 @@ "ui.selectOther": "Вибрати інше…", "ui.selectTangentChain": "Вибрати дотичний ланцюг", "ui.select_all": "Виділити все", + "ui.select_components_by_face": "Вибрати компоненти (грань вибирає свій компонент)", "ui.selection": "Виділення", "ui.selection_modifiers": "Модифікатори вибору", "ui.selection_shift_add_only": "Shift+клацання додає без вилучення", @@ -4695,6 +5042,10 @@ "ui.viewport_status_section_plane": "Поточна площина", "ui.viewport_status_section_tilted": "нахилена · {offset}", "ui.viewport_status_stripes": "Смуг: {count}", + "ui.viewport_status_summary_clear": "Вимкнути все", + "ui.viewport_status_summary_heading": "На цьому виді", + "ui.viewport_status_summary_label": "{name} і ще елементів: {count}", + "ui.viewport_status_summary_more": "+{count}", "ui.viewport_theme_follow": "За темою", "ui.visual_shaded_hidden_edges": "Затінений із прихованими ребрами", "ui.visual_shaded_with_edges": "Затінений із ребрами", diff --git a/assets/locales/vi.json b/assets/locales/vi.json index 5a7cfc72a6..363bd82458 100644 --- a/assets/locales/vi.json +++ b/assets/locales/vi.json @@ -822,6 +822,7 @@ "cmd.appearance_pick_up": "Lấy diện mạo", "cmd.appearance_replace": "Thay diện mạo", "cmd.assembly_bom": "Bảng kê vật tư (BOM)", + "cmd.assembly_mass_properties": "Thuộc tính khối lượng", "cmd.body.print_face": "Mặt in", "cmd.body_rename": "Đổi tên thân khối", "cmd.browser_collapse_all": "Thu gọn tất cả", @@ -1247,6 +1248,7 @@ "cmd.view.render_browser": "Trình duyệt kết xuất", "cmd.view.render_dock": "Di chuyển neo kết xuất", "cmd.view.render_focus": "Chế độ xem tập trung kết xuất", + "cmd.view.status_details": "Chi tiết trạng thái khung nhìn", "cmd.view.status_items": "Các mục trạng thái khung nhìn", "cmd.view_ambient_occlusion": "Đổ bóng che khuất môi trường (AO)", "cmd.view_angle_readout": "Góc hướng nhìn", @@ -1285,8 +1287,14 @@ "color.hex_hint": "#rrggbb", "color.hue": "Sắc độ", "color.linear": "Đang hiển thị giá trị màu trong không gian tuyến tính 0-1", + "color.recent": "Màu gần đây", + "color.recent_help": "Các màu bạn chọn ở bất kỳ ô màu nào sẽ hiện ở đây", + "color.saturation": "Độ bão hòa (phần trăm)", "color.saturation_value": "Độ bão hòa và giá trị", "color.selected": "Màu đã chọn", + "color.step_down": "Giảm một bước", + "color.step_up": "Tăng một bước", + "color.value": "Giá trị, độ sáng (phần trăm)", "config.column_filter_empty": "Không có cột phù hợp", "contact.components_count": "{count} chi tiết", "contact.enabled_label": "đã bật", @@ -1333,6 +1341,7 @@ "dialog.dimension_name": "Kích thước: {name}", "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.", "dialog.select": "↖ Chọn", "dialog.selected_count": "↖ {count} đã chọn", "dialog.selection_guidance": "Chọn một mục đầu vào để thay đổi. Nhấp vào đối tượng đã chọn để bỏ chọn; Ctrl/Cmd-nhấp để bật/tắt chọn. Xem trước kết quả, sau đó nhấn OK.", @@ -1390,21 +1399,168 @@ "from the rule": "từ quy tắc", "help.apache": "Giấy phép Apache 2.0", "help.appearance.assign": "Gán màu hoặc vẻ ngoài vật liệu cho các khối, mặt hoặc thành phần đã chọn mà không thay đổi khối lượng. Dùng để phân biệt các chi tiết hoặc xem trước lớp hoàn thiện.", + "help.assembly.bom": "Liệt kê các chi tiết của thiết kế hoặc cụm chi tiết kèm số lượng theo mã chi tiết, và có thể lưu danh sách dạng CSV hoặc JSON. Dùng làm bảng kê vật tư để đặt hàng và lắp ráp.", "help.attributions": "Ghi công (ATTRIBUTION.md)", "help.build": "Phiên bản {version} · commit {commit} · dựng ngày {date}", + "help.canvas.decal": "Đặt hình ảnh lên một mặt phẳng của mô hình như một đề can. Dùng để thể hiện nhãn, logo hoặc hoa văn in trên chi tiết.", + "help.canvas.edit": "Thay đổi hình ảnh Canvas: vị trí, kích thước, góc, độ mờ đục, phản chiếu, hiển thị hoặc tên. Dùng để căn hình ảnh tham chiếu với bản phác thảo mà bạn vẽ đè lên ảnh.", + "help.canvas.insert": "Đặt hình ảnh PNG hoặc JPEG lên mặt phẳng hoặc bề mặt làm tham chiếu bán trong suốt. Dùng để đồ lại ảnh chụp hoặc bản vẽ trong phác thảo, với ảnh được chỉnh về kích thước đã biết.", + "help.component.break_link": "Ngắt liên kết cụm chi tiết với tệp thiết kế của nó, giữ nguyên các thân khối. Dùng khi chi tiết không còn cần cập nhật theo thay đổi trong tệp gốc.", + "help.component.capture_position": "Lưu vị trí các chi tiết đã di chuyển bằng tay làm vị trí chính thức của chúng. Dùng sau khi kéo các chi tiết vào vị trí mà không có khớp nối nào giữ chúng.", + "help.component.create": "Thêm một cụm chi tiết mới, rỗng vào thiết kế, bên trong cụm đang hoạt động trừ khi bạn chọn cụm cha khác. Dùng để dành không gian riêng cho các thân khối và phác thảo của một chi tiết mới trong cụm lắp ráp.", "help.component.finish_edit": "Lưu chi tiết đang được chỉnh sửa tại chỗ, quay lại cụm lắp ráp của nó và cập nhật cụm lắp ráp từ chi tiết đó. Dùng khi bạn đã hoàn tất các thay đổi cho chi tiết liên kết.", + "help.component.from_bodies": "Biến mỗi thân khối được chọn thành một cụm chi tiết riêng, lấy tên theo thân khối. Dùng khi cần chuyển thiết kế gồm các thân khối rời thành cụm lắp ráp có các chi tiết nối và di chuyển được.", + "help.component.insert_design": "Chèn một thiết kế đã lưu khác thành cụm chi tiết liên kết với tệp của nó, giữ vị trí các thân khối đặc như trong tệp đó. Dùng để tái sử dụng chi tiết trong cụm lắp ráp và nhận các thay đổi sau này bằng Cập nhật liên kết.", + "help.component.properties": "Hiển thị thể tích, khối lượng, trọng tâm và mô men quán tính của một cụm chi tiết, dựa trên vật liệu của các thân khối trong đó. Dùng để kiểm tra khối lượng và độ cân bằng của cụm lắp ráp.", + "help.component.revert_position": "Đưa các chi tiết đã di chuyển bằng tay về vị trí cũ, bỏ những lần di chuyển chưa lưu vị trí. Dùng để hoàn tác các lần di chuyển thử trong cụm lắp ráp.", + "help.component.update_links": "Đọc lại các tệp thiết kế được liên kết và thay thế những thân khối đã thay đổi. Dùng sau khi chỉnh sửa một chi tiết mà cụm lắp ráp này sử dụng.", + "help.config.configure": "Thêm tham số, tính năng, vật liệu hoặc ngoại quan làm cột của bảng cấu hình. Dùng để tạo các biến thể của một thiết kế, chẳng hạn các kích cỡ của một chi tiết.", + "help.config.preview": "Kiểm tra xem một hàng của bảng cấu hình có dựng lại được không, mà không thay đổi thiết kế hay lịch sử của nó. Dùng trước khi chuyển sang một biến thể bạn chưa chắc chắn.", + "help.config.rules": "Hiển thị các quy tắc của bảng cấu hình, các hàng và hàng đang kích hoạt. Dùng để xem các biến thể của thiết kế được thiết lập thế nào.", + "help.config.table": "Hiển thị bảng cấu hình: mỗi biến thể một hàng, mỗi tham số hoặc tính năng một cột, và cho biết thiết kế còn khớp với hàng đang kích hoạt hay không. Dùng để rà soát các biến thể của thiết kế.", "help.connect_note": "Khởi động lại từng trợ lý ảo để nạp SolidCraft server. Mỗi tập tin cấu hình đều đã được sao lưu thành .bak trước khi thay đổi.", "help.connect_title": "Kết nối trợ lý ảo AI", + "help.construct.axis.edge": "Tạo trục dựng hình dọc theo một cạnh thẳng hoặc đường phác thảo. Dùng làm trục cho Xoay tròn tạo khối, mảng tròn hoặc khớp nối.", + "help.construct.axis.normal_to_face": "Tạo trục dựng hình đi qua một điểm và vuông góc với một mặt. Dùng làm trục nhô thẳng ra khỏi bề mặt.", + "help.construct.axis.through_cylinder": "Tạo trục dựng hình đi qua tâm của mặt hình trụ, nón hoặc xuyến, hoặc của một cạnh tròn. Dùng để xoay hoặc tạo mảng các tính năng quanh một chi tiết tròn.", + "help.construct.axis.two_planes": "Tạo trục dựng hình tại nơi hai mặt phẳng giao nhau. Dùng khi trục cần có chính là đường giao giữa hai mặt.", + "help.construct.axis.two_points": "Tạo trục dựng hình đi qua hai điểm, đỉnh hoặc điểm phác thảo. Dùng cho trục chạy giữa hai vị trí đã biết.", + "help.construct.plane.along_path": "Tạo mặt phẳng dựng hình vuông góc với một cạnh hoặc đường cong, tại một vị trí dọc theo nó. Dùng để phác thảo biên dạng cho Quét theo đường dẫn hoặc mặt cắt của đường dẫn.", + "help.construct.plane.at_angle": "Tạo mặt phẳng dựng hình xoay một góc so với mặt phẳng hoặc mặt, quanh một trục hoặc cạnh. Dùng cho phác thảo nghiêng, chẳng hạn giá đỡ vát góc.", + "help.construct.plane.midplane": "Tạo mặt phẳng dựng hình nằm giữa hai mặt phẳng, hoặc phân giác của chúng nơi chúng giao nhau theo một góc. Dùng làm mặt phẳng đối xứng khi Lấy đối xứng.", + "help.construct.plane.offset": "Tạo mặt phẳng dựng hình song song với một mặt phẳng, cách một khoảng hoặc đi qua một điểm. Dùng để phác thảo ở độ cao xác định phía trên một mặt.", + "help.construct.plane.perpendicular": "Tạo mặt phẳng dựng hình chứa một đường hoặc cạnh và vuông góc với một mặt phẳng hoặc mặt. Dùng để phác thảo thẳng đứng trên mô hình dọc theo một cạnh.", + "help.construct.plane.tangent": "Tạo mặt phẳng dựng hình tiếp xúc với hình trụ, nón, cầu hoặc xuyến tại một điểm. Dùng để phác thảo trên mặt bên của một chi tiết tròn.", + "help.construct.plane.three_points": "Tạo mặt phẳng dựng hình đi qua ba điểm. Dùng cho mặt phẳng xác định bởi các vị trí trên mô hình, chẳng hạn ba góc.", + "help.construct.plane.two_edges": "Tạo mặt phẳng dựng hình đi qua hai cạnh thẳng hoặc hai đường nằm trong cùng một mặt phẳng. Dùng cho mặt phẳng cắt ngang chi tiết, chẳng hạn qua hai cạnh đối diện.", + "help.construct.point.along_path": "Tạo điểm dựng hình tại một vị trí dọc theo cạnh hoặc đường cong. Dùng làm điểm tham chiếu ở giữa đường dẫn.", + "help.construct.point.center": "Tạo điểm dựng hình tại tâm của cạnh tròn, đường tròn hoặc cung phác thảo, hình cầu hoặc hình xuyến. Dùng làm điểm tham chiếu cho lỗ, trục và khớp nối.", + "help.construct.point.edge_plane": "Tạo điểm dựng hình tại nơi một cạnh hoặc đường xuyên qua một mặt phẳng. Dùng để đánh dấu chỗ đường dẫn cắt qua mặt phẳng.", + "help.construct.point.three_planes": "Tạo điểm dựng hình tại nơi ba mặt phẳng giao nhau. Dùng làm điểm tham chiếu cho góc, kể cả khi mô hình không có đỉnh ở đó.", + "help.construct.point.two_edges": "Tạo điểm dựng hình tại nơi hai cạnh hoặc hai đường gặp nhau, hoặc gần nhau nhất. Dùng làm điểm tham chiếu tại một góc ảo.", + "help.construct.point.vertex": "Tạo điểm dựng hình trên một đỉnh hoặc điểm phác thảo. Dùng làm điểm tham chiếu vẫn còn khi phác thảo hay thân khối xung quanh bị ẩn.", + "help.contact.check": "Liệt kê các cặp chi tiết trong tập hợp tiếp xúc đang xuyên qua nhau ở vị trí hiện tại. Dùng để tìm va chạm trước khi dẫn động khớp nối hoặc chạy nghiên cứu chuyển động.", + "help.contact.create": "Gom hai hay nhiều chi tiết thành tập hợp tiếp xúc, ngăn các thân khối xuyên qua nhau khi khớp nối chuyển động. Dùng cho các chi tiết cần dừng khi chạm nhau, như đòn bẩy chạm cữ chặn.", + "help.contact.disable_all": "Tắt tiếp xúc để khớp nối được dẫn động và nghiên cứu chuyển động không còn dừng khi các chi tiết chạm nhau. Dùng để di chuyển chi tiết tự do trở lại hoặc giữ cho cụm lắp ráp lớn phản hồi nhanh.", + "help.contact.edit": "Thay đổi tập hợp tiếp xúc: các chi tiết, tên hoặc trạng thái tạm nén, đồng thời có thể xóa tập hợp. Dùng để điều chỉnh những chi tiết không được xuyên qua nhau.", + "help.contact.enable_all": "Đưa mọi chi tiết của cụm lắp ráp vào một tập hợp tiếp xúc để không có hai chi tiết nào xuyên qua nhau khi khớp nối chuyển động. Dùng để kiểm tra nhanh toàn bộ cơ cấu.", + "help.contact.enable_sets": "Bật tiếp xúc cho các tập hợp tiếp xúc đã định nghĩa để khớp nối được dẫn động và nghiên cứu chuyển động dừng tại nơi các chi tiết sắp xuyên qua nhau. Dùng để bật lại các tập hợp sau khi tắt tiếp xúc.", + "help.contact.list": "Liệt kê các tập hợp tiếp xúc của thiết kế, các chi tiết trong mỗi tập hợp và trạng thái bật tiếp xúc. Dùng để kiểm tra thiết lập tiếp xúc trước khi dẫn động cụm lắp ráp.", + "help.document.inspect": "Hiển thị tổng quan về thiết kế: các cụm chi tiết, thân khối, phác thảo, tính năng và tham số. Dùng để nắm nhanh một thiết kế do người khác tạo.", + "help.document.units": "Thay đổi đơn vị hiển thị chiều dài của thiết kế, chẳng hạn mm hoặc inch. Hình học giữ nguyên kích thước; chỉ phần hiển thị và các số thuần túy thay đổi theo.", + "help.edit.checkpoint": "Ghi nhớ thiết kế, phác thảo đang hoạt động và lịch sử hoàn tác ở trạng thái hiện tại. Dùng trước khi thử điều gì rủi ro mà bạn có thể muốn quay lại.", + "help.edit.restore_checkpoint": "Quay về một điểm kiểm tra, kể cả lịch sử hoàn tác. Dùng để bỏ mọi thao tác đã làm kể từ điểm kiểm tra chỉ trong một bước.", + "help.explode.create": "Tạo chế độ xem phân rã, đẩy các chi tiết ra xa tâm cụm lắp ráp hoặc theo các dịch chuyển bạn thiết lập. Dùng để thể hiện cách lắp các chi tiết trong hướng dẫn hoặc bài trình bày.", "help.face.delete": "Xóa các mặt đã chọn và để các mặt lân cận khép kín chỗ trống, hoặc giữ lỗ hở dưới dạng bề mặt. Dùng để bỏ một cạnh bo tròn, một lỗ hoặc một phần lồi đang vướng.", + "help.file.insert_mesh": "Thêm tệp 3MF, STL, OBJ, PLY hoặc glTF vào thiết kế dưới dạng các thân khối lưới trong cụm chi tiết mới. Dùng để đặt mô hình tải về hoặc quét bên cạnh thiết kế làm tham chiếu.", + "help.file.insert_step": "Thêm tệp STEP, IGES, FreeCAD hoặc Rhino vào thiết kế dưới dạng các thân khối đặc trong cụm chi tiết mới; các chi tiết của cụm lắp ráp trở thành cụm chi tiết lồng nhau. Dùng để nhập mô hình từ nhà cung cấp hoặc chương trình CAD khác.", + "help.file.save_mesh": "Lưu các thân khối thành lưới đa giác STL, 3MF hoặc OBJ, với độ mịn của các mặt cong do bạn chọn. Dùng để chuẩn bị chi tiết cho in 3D hoặc cho các chương trình đọc lưới đa giác.", + "help.file.send_to_slicer": "Lưu các thân khối đã chọn thành tệp 3MF và mở trong phần mềm cắt lớp bạn chọn. Dùng để chuyển từ mô hình sang in chỉ trong một bước.", "help.font_details": "Giao diện sử dụng các phông chữ tích hợp của egui (Ubuntu Light và Hack, theo Ubuntu Font Licence và giấy phép MIT/Bitstream Vera; Noto Emoji và phông biểu tượng emoji, theo OFL và MIT). Văn bản phác thảo dùng Liberation Sans (SIL OFL 1.1). Gói phông chữ tùy chọn của Crafting Apps, craft-fonts (BIZ UDPGothic, Shippori Mincho, BIZ UDMincho, Noto Sans CJK SC, Noto Sans Arabic; SIL OFL 1.1), không được đóng gói kèm trong các bản dựng SolidCraft.", "help.fonts": "Phông chữ", + "help.frame.member": "Đặt một biên dạng, chẳng hạn ống hoặc nhôm định hình, dọc theo các đường của phác thảo khung, mỗi đường một phần tử. Dùng để dựng khung, giá đỡ và kết cấu hàn.", + "help.inspect.accessibility": "Tô màu các mặt mà dụng cụ có thể tiếp cận từ một hướng và các mặt không tiếp cận được. Dùng để kiểm tra chi tiết có gia công hoặc ép khuôn được từ phía đó không.", + "help.inspect.center_of_mass": "Hiển thị trọng tâm của các thân khối, từng thân riêng và tất cả cùng nhau, với giả định mật độ đồng đều. Dùng để kiểm tra cân bằng và chỗ chi tiết sẽ bị lật.", + "help.inspect.colored_components": "Gán cho mỗi cụm chi tiết một màu tạm thời riêng, không thay đổi ngoại quan. Dùng để phân biệt các chi tiết của cụm lắp ráp chỉ trong một cái nhìn.", + "help.inspect.compare": "So sánh hai thân khối và chỉ ra chỗ nào một thân có vật liệu mà thân kia không có, kèm thể tích và độ lệch. Dùng để đối chiếu chi tiết đã chỉnh sửa hoặc nhập vào với bản gốc.", + "help.inspect.continuity": "Kiểm tra các mặt gặp nhau mượt đến đâu dọc theo cạnh: khe hở, gãy góc hoặc nhảy độ cong, và tô bóng các đường nối. Dùng để tìm chỗ các bề mặt nối chưa sạch.", + "help.inspect.curvature_comb": "Vẽ các lược dọc theo đường cong, cạnh hoặc mặt, với răng lược cho thấy mức độ uốn cong. Dùng để phát hiện chỗ phẳng, chỗ gãy và độ cong không đều.", + "help.inspect.curvature_map": "Tô màu các mặt theo độ cong, màu đỏ ở nơi bán kính nhỏ. Dùng để tìm chỗ gấp gắt và đánh giá độ mượt của bề mặt.", + "help.inspect.draft": "Tô màu các mặt theo góc thoát khuôn so với hướng tháo khuôn, đánh dấu những mặt có góc quá nhỏ. Dùng để kiểm tra chi tiết trước khi ép khuôn hoặc đúc.", + "help.inspect.environment_map": "Hiển thị mô hình với lớp hoàn thiện phản chiếu như gương. Dùng để đánh giá độ uôn lượn của bề mặt qua cách các hình phản chiếu chạy trên đó.", + "help.inspect.interference": "Tìm các thân khối chồng lấn nhau và hiển thị từng vùng chồng lấn kèm thể tích. Dùng để kiểm tra cụm lắp ráp xem có chi tiết nào va chạm không.", + "help.inspect.isocurve": "Vẽ các đường đẳng trị của mặt, tức lưới mà bề mặt được xây dựng trên đó. Dùng để xem bề mặt chạy ra sao và chỗ nào các đường dồn lại.", + "help.inspect.measure": "Đo chiều dài, khoảng cách, góc, diện tích và bán kính giữa các đối tượng bạn chọn, hoặc thể tích và khối lượng của thân khối. Dùng để kiểm tra kích thước và khe hở.", + "help.inspect.minimum_radius": "Tìm bán kính cong nhỏ nhất của đường cong, cạnh hoặc mặt và vị trí của nó. Dùng để kiểm tra dụng cụ hoặc đầu phun có đi theo được hình dạng này không.", + "help.inspect.section": "Cắt mở khung nhìn theo một mặt phẳng và tô gạch các mặt bị cắt, không thay đổi mô hình. Dùng để nhìn vào bên trong chi tiết và cụm lắp ráp.", + "help.inspect.zebra": "Hiển thị các sọc đen trắng phản chiếu trên các mặt của mô hình. Dùng để đánh giá độ mượt: sọc bị đứt hoặc gãy tại một cạnh cho thấy chỗ chuyển tiếp kém.", + "help.joint.as_built": "Nối hai chi tiết tại vị trí hiện có mà không di chuyển chi tiết nào, đồng thời đặt cách chúng còn có thể chuyển động. Dùng cho các chi tiết đã được đặt ở vị trí cuối cùng.", + "help.joint.cam": "Liên kết cam quay với con đội để con đội trượt hoặc lắc khi biên dạng cam đi qua bên dưới. Dùng cho cam, cơ cấu lệch tâm và bộ dẫn động van.", + "help.joint.create": "Nối hai chi tiết tại các gốc tọa độ khớp nối bạn chọn và đưa chi tiết thứ hai vào vị trí; loại khớp nối quyết định cách chúng còn có thể chuyển động. Dùng để dựng cụm lắp ráp có các chi tiết chuyển động như cơ cấu thực.", + "help.joint.drive": "Đặt góc hoặc khoảng cách của khớp nối và di chuyển các chi tiết theo nó trong giới hạn cho phép; các khớp liên kết và vòng kín chuyển động theo. Dùng để kiểm tra cơ cấu có chuyển động đúng dự kiến hay không.", + "help.joint.gear": "Liên kết hai khớp quay theo tỉ số truyền bánh răng để dẫn động khớp này làm khớp kia quay. Dùng cho bánh răng trụ răng thẳng và bánh răng vành, với tỉ số từ bán kính vòng chia hoặc số răng.", + "help.joint.limits": "Đặt giới hạn nhỏ nhất và lớn nhất cho góc quay hoặc hành trình trượt của khớp nối, hoặc xóa các giới hạn đó. Dùng để giữ bản lề, con trượt hoặc đòn bẩy trong phạm vi chuyển động thực tế.", + "help.joint.motion_link": "Ghép hai khớp nối theo tỉ số và độ lệch để dẫn động khớp này làm khớp kia chuyển động. Dùng cho quan hệ cố định giữa các khớp mà Khớp bánh răng, Khớp thanh răng - bánh răng hoặc Khớp trục vít không đáp ứng.", + "help.joint.origin": "Đặt gốc tọa độ khớp nối có tên trên chi tiết: tại tâm mặt hoặc đầu tròn, trung điểm cạnh, góc hoặc một điểm. Dùng để chuẩn bị vị trí gắn khớp nối; gốc chọn trên mặt sẽ đi theo mặt khi chi tiết đổi kích thước.", + "help.joint.rack_pinion": "Liên kết bánh răng quay với thanh răng trượt để thanh răng di chuyển bằng độ dài cung vòng chia mà bánh răng lăn qua. Dùng cho bộ truyền thanh răng, cơ cấu lái và bàn trượt tuyến tính.", + "help.joint.reach": "Di chuyển một điểm trên chi tiết về phía đích bằng cách thay đổi các khớp trong chuỗi của nó, trong giới hạn cho phép. Dùng để đặt tư thế cơ cấu theo vị trí mong muốn của đầu mút, như đầu cánh tay rô bốt.", + "help.joint.rigid_group": "Khóa các chi tiết đã chọn với nhau ở vị trí hiện tại để chúng chuyển động như một khối. Dùng cho các chi tiết thuộc cùng một nhóm và không chuyển động tương đối với nhau.", + "help.joint.screw": "Liên kết chuyển động quay và trượt theo bước vít để mỗi vòng quay tiến được khoảng cách đó. Dùng cho vít me, bu lông và trục có ren.", + "help.library.apply": "Áp dụng vật liệu hoặc ngoại quan từ tệp thư viện cục bộ lên thân khối, mặt hoặc cụm chi tiết. Dùng để tái sử dụng vật liệu đã lưu từ thiết kế khác.", + "help.library.export": "Lưu vật liệu và ngoại quan của thiết kế vào tệp thư viện. Dùng để chia sẻ hoặc tái sử dụng trong các thiết kế khác.", + "help.library.inspect": "Kiểm tra một tệp thư viện và liệt kê các vật liệu, ngoại quan trong đó, không thay đổi thiết kế. Dùng để xem thư viện có gì trước khi áp dụng.", "help.licences": "Giấy phép", "help.material.assign": "Đặt vật liệu cho các khối đã chọn, chẳng hạn thép, nhôm hoặc ABS. Dùng để khối lượng và trọng lượng khớp với chi tiết thật.", + "help.material.properties.validate": "Kiểm tra thẻ dữ liệu vật liệu xem các số liệu có nhất quán và có thiếu giá trị nào không. Dùng trước khi dựa vào dữ liệu này để tính khối lượng hay độ cứng.", + "help.mesh.convert": "Dựng lại thân khối dạng lưới, chẳng hạn tệp STL, thành khối đặc có thể chỉnh sửa với các mặt phẳng và mặt trụ. Dùng để sửa các chi tiết cơ khí nhập vào như một thân khối tự dựng.", "help.mit": "Giấy phép MIT", + "help.motion.study": "Đặt giá trị khớp nối tại các bước trên dòng thời gian và phát lại dưới dạng hoạt ảnh. Dùng để quan sát cơ cấu chuyển động theo chuỗi và kiểm tra khe hở trên đường đi.", + "help.motion.trace": "Theo dõi quỹ đạo của một điểm trên chi tiết khi phát nghiên cứu chuyển động hoặc dẫn động khớp nối, rồi giữ lại dưới dạng đường cong phác thảo 3D khi xác nhận. Dùng để xem tầm vươn của cơ cấu hoặc dựng hình dựa trên quỹ đạo đó.", "help.notice": "NOTICE", + "help.occurrence.ground": "Cố định chi tiết tại chỗ hoặc giải phóng nó trở lại để các khớp nối di chuyển những chi tiết khác quanh nó. Dùng cho khung hoặc đế làm nền cho phần còn lại của cụm lắp ráp.", "help.occurrence.move": "Chỉ di chuyển chi tiết lắp ráp theo các bậc tự do mà khớp nối cho phép. Chọn một mặt của chi tiết, rồi kéo mũi tên hoặc cung tròn có thể sử dụng.", "help.parameters.change": "Mở bảng các giá trị có tên và biểu thức điều khiển kích thước của thiết kế. Dùng để sửa một kích thước ở một chỗ và mọi tính năng dùng nó sẽ cập nhật theo.", + "help.parameters.export": "Lưu các tham số của thiết kế dạng CSV hoặc JSON. Dùng để chỉnh sửa trong bảng tính hoặc chuyển sang thiết kế khác.", + "help.parameters.graph": "Hiển thị tham số và tính năng nào dùng tham số, tính năng nào khác, cùng các vòng lặp hoặc lỗi giữa chúng. Dùng để biết thay đổi một tham số sẽ ảnh hưởng đến những gì.", + "help.parameters.import": "Đọc tham số từ CSV hoặc JSON vào thiết kế, hoặc nhập tất cả hoặc không nhập gì. Dùng để đưa giá trị từ bảng tính hoặc thiết kế khác vào.", + "help.parameters.move": "Di chuyển một tham số người dùng lên trước hoặc xuống sau tham số khác trong danh sách. Dùng để giữ các tham số liên quan gần nhau.", + "help.parts.fastener": "Thêm vít, bu lông, đai ốc, vòng đệm, chốt hoặc ổ lăn tiêu chuẩn theo kích thước bạn chọn. Dùng tại lỗ để chi tiết liên kết tự lắp vào bằng khớp nối, giúp bạn không phải tự dựng hình chi tiết liên kết.", + "help.parts.insert": "Chèn chi tiết tiêu chuẩn theo họ, kích thước và chiều dài bạn chọn. Dùng tại vành hoặc thành lỗ để chi tiết lắp vào lỗ bằng khớp ngàm cứng.", + "help.parts.library": "Mở thư viện chi tiết tiêu chuẩn: vít, bu lông, đai ốc, vòng đệm, vít hãm, chốt định vị và ổ lăn theo kích thước tiêu chuẩn. Dùng để thêm chi tiết tiêu chuẩn thay vì tự dựng hình.", + "help.parts.resize": "Thay đổi kích thước hoặc chiều dài của chi tiết tiêu chuẩn đã chèn và dựng lại nó; các khớp nối giữ chi tiết đúng vị trí lắp. Dùng khi vít quá dài hoặc lỗ cần kích thước khác.", + "help.plastic.assign_rule": "Gán một quy tắc nhựa kỹ thuật cho các thân khối, để các tính năng nhựa của chúng lấy góc thoát khuôn và khe hở từ đó làm mặc định. Dùng để thiết lập chi tiết ép khuôn một lần cho mọi tính năng.", + "help.plastic.boss": "Thêm vấu bắt ốc vỏ nhựa lên một mặt: một trụ có thể kèm lỗ, góc thoát khuôn, Bo tròn mép ở chân và các gân tăng cứng. Dùng cho điểm bắt vít trong vỏ ép khuôn hoặc in 3D.", + "help.plastic.lip": "Thêm gờ khớp mí hoặc rãnh tương ứng dọc theo mép của thân khối đã khoét rỗng. Dùng để hai nửa vỏ định vị vào nhau và đóng khít.", + "help.plastic.manage_rules": "Tạo và chỉnh sửa quy tắc nhựa kỹ thuật: vật liệu, độ dày thành, bán kính, khe hở và góc thoát khuôn. Dùng để các tính năng của chi tiết ép khuôn luôn nhất quán.", + "help.plastic.rest": "Thêm vấu đỡ nhựa lên một mặt: đệm tròn hoặc chữ nhật, đặc hoặc rỗng, có góc thoát khuôn. Dùng làm chân đỡ hoặc trụ kê trong vỏ ép khuôn.", + "help.plastic.snap_fit": "Thêm móc khớp gài bấm lên một mặt, với chiều dài, độ dày và chốt hãm. Dùng để gài các chi tiết vào nhau mà không cần vít.", "help.pointcloud.insert": "Chèn bản quét PLY, XYZ hoặc PTS dưới dạng đám mây điểm bên cạnh mô hình. Dùng làm tham chiếu khi dựng mô hình từ dữ liệu đo.", + "help.sheet.convert": "Biến một tấm hoặc thân khối khoét rỗng có độ dày đều thành thân kim loại tấm, với các chỗ uốn tại cạnh bạn chọn. Dùng để làm cho chi tiết có sẵn trải phẳng được.", + "help.sheet.export_bend_table": "Lưu bảng chấn uốn của thân kim loại tấm dạng CSV hoặc JSON: góc, bán kính và vị trí chấn trên bản trải phẳng. Dùng cho máy chấn hoặc xưởng gia công.", + "help.sheet.export_dxf": "Lưu bản trải phẳng dạng DXF, gồm đường viền, đường chấn và ghi chú chấn tùy chọn. Dùng cho cắt laser, plasma hoặc tia nước.", + "help.sheet.flange": "Thêm chấn gập mép tôn: tấm đế từ phác thảo, thành dọc theo đường viền phác thảo hở, hoặc thành uốn trên cạnh tấm. Dùng để dựng các chi tiết kim loại tấm.", + "help.sheet.flat_pattern": "Tạo bản trải phẳng của thân kim loại tấm, gồm kích thước, các lỗ cắt và đường chấn. Dùng để kiểm tra phôi trước khi cắt.", + "help.sheet.fold": "Uốn tấm kim loại dọc theo một đường phác thảo với góc và bán kính cho trước. Dùng để gập tấm phẳng sau khi đã tạo các chi tiết cắt trên đó.", + "help.sheet.hem": "Gấp mép tấm kim loại ngược lại lên chính nó. Dùng để có mép an toàn, cứng cáp và không còn ba via sắc.", + "help.sheet.manage_rules": "Tạo và chỉnh sửa quy tắc kim loại tấm: độ dày, bán kính uốn, K-factor, rãnh giảm ứng suất và khe hở. Dùng để khớp với vật liệu và dụng cụ của xưởng bạn.", + "help.sheet.path_flange": "Thêm một chuỗi chấn gập mép tôn dọc theo cạnh tấm, theo một chuỗi đường phác thảo hở. Dùng cho các biên dạng như chữ U hoặc Z chỉ trong một bước.", + "help.sheet.perforate": "Thêm một vùng lỗ phủ toàn bộ tấm phẳng, theo kiểu so le hoặc vuông. Dùng cho lỗ thông gió, lưới chắn và tấm nhẹ hơn.", + "help.sheet.refold": "Gập lại thân kim loại tấm đã được trải phẳng. Dùng sau khi thêm tính năng lên tấm phẳng.", + "help.sheet.unfold": "Trải phẳng mọi chỗ uốn của thân kim loại tấm. Dùng để thêm lỗ hoặc đường cắt phải băng qua chỗ uốn, rồi Gập lại tấm kim loại.", + "help.sketch.arc.center_point": "Vẽ cung tròn quanh một tâm, từ điểm đầu đến điểm cuối. Dùng khi đã biết tâm cung, chẳng hạn bán kính quanh lỗ hoặc trục xoay.", + "help.sketch.arc.tangent": "Vẽ cung tròn bắt đầu từ đầu mút của đường thẳng hoặc cung và tiếp tuyến với nó. Dùng để nối tiếp chuỗi bằng chỗ uốn mượt thay vì góc nhọn.", + "help.sketch.arc.three_point": "Vẽ cung tròn từ điểm đầu đến điểm cuối đi qua điểm thứ ba. Dùng khi cung phải đi qua các điểm cho trước chứ không quanh một tâm đã biết.", + "help.sketch.arc3d": "Vẽ cung tròn chính xác trong không gian qua ba điểm, khi bật 3D Phác thảo. Dùng cho các chỗ uốn của đường dẫn và ray dẫn hướng nằm ngoài mặt phẳng phác thảo.", + "help.sketch.auto_constrain": "Thêm các ràng buộc mà phác thảo gần như đã thỏa, như nằm ngang, song song hay trùng khớp, rồi thêm kích thước cho đến khi xác định hoàn toàn. Dùng để khóa một phác thảo vẽ tay chỉ trong một bước.", + "help.sketch.auto_constrain_from_datum": "Hoạt động như Tự động gán ràng buộc, nhưng đo vị trí từ điểm chuẩn bạn chọn thay vì gốc tọa độ. Dùng khi chi tiết được bố trí từ một điểm tham chiếu, như góc hoặc lỗ.", + "help.sketch.blend_curve": "Nối đầu mút của hai đường cong bằng một spline mượt tiếp tuyến với cả hai, hoặc liên tục cả độ cong nếu bạn chọn. Dùng để lấp khoảng hở giữa các đường cong mà không bị gãy.", + "help.sketch.break": "Tách đường cong thành các đoạn tại chỗ các đường cong khác cắt qua, hoặc tại các điểm bạn đặt lên nó. Dùng khi một đoạn cần ràng buộc riêng hoặc cần xóa riêng.", + "help.sketch.cancel": "Thoát phác thảo và bỏ mọi thay đổi kể từ khi tạo hoặc mở ra để chỉnh sửa. Hoàn tác sẽ khôi phục lại các thay đổi.", + "help.sketch.centerline": "Chuyển đường thẳng giữa đường thường và đường tâm, tức trục mà biên dạng bỏ qua. Dùng đường tâm làm trục cho Xoay tròn tạo khối, đối xứng gương hoặc ràng buộc đối xứng.", + "help.sketch.chamfer.distance_angle": "Vát mép góc giữa hai đường thẳng theo khoảng cách dọc đường thứ nhất và một góc. Dùng khi bản vẽ cho kích thước vát mép bằng chiều dài và góc, như 2 mm × 30°.", + "help.sketch.chamfer.equal_distance": "Vát mép góc giữa hai đường thẳng với cùng khoảng cách dọc theo cả hai. Dùng cho kiểu vát mép 45° thông thường trên đường bao.", + "help.sketch.chamfer.two_distance": "Vát mép góc giữa hai đường thẳng với khoảng cách riêng dọc theo từng đường. Dùng cho vát mép không phải 45°, cho bằng hai chiều dài.", + "help.sketch.circle.center": "Vẽ đường tròn từ tâm và đường kính. Dùng cho lỗ, vấu và các chi tiết tròn khác đã biết tâm.", + "help.sketch.circle.three_point": "Vẽ đường tròn đi qua ba điểm. Dùng khi đường tròn phải đi qua các điểm cho trước, chẳng hạn của hình học có sẵn.", + "help.sketch.circle.three_tangent": "Vẽ đường tròn tiếp xúc với ba đường thẳng, đường tròn hoặc cung. Dùng để đặt đường tròn vào khoảng giữa ba đường cong, như đường tròn nội tiếp của tam giác.", + "help.sketch.circle.two_point": "Vẽ đường tròn từ hai đầu mút của đường kính. Dùng khi biết các điểm trên mép đường tròn chứ chưa biết tâm.", + "help.sketch.circle.two_tangent": "Vẽ đường tròn có kích thước cho trước tiếp xúc với hai đường thẳng, đường tròn hoặc cung. Dùng để bo tròn với bán kính cố định giữa hai đường cong.", + "help.sketch.conic": "Vẽ đường conic giữa hai đầu mút, định hình bằng một điểm đỉnh và giá trị rho. Dùng cho các chỗ chuyển tiếp mượt mà bạn muốn chỉnh độ phồng, từ phẳng đến gần như góc nhọn.", + "help.sketch.constrainer": "Áp dụng ràng buộc mà một hoặc hai đường cong đã chọn gần như đã thỏa, như nằm ngang, song song, tiếp tuyến hay bằng nhau. Dùng để chỉnh lại phác thảo vẽ tay từng quan hệ một.", + "help.sketch.constraint.coincident": "Làm một điểm trùng với điểm khác, nằm trên một đường cong hoặc trục phác thảo. Dùng để đóng khe hở trong biên dạng hoặc đặt điểm lên đường thẳng.", + "help.sketch.constraint.collinear": "Làm hai đường thẳng nằm trên cùng một đường thẳng. Dùng để căn thẳng hàng các cạnh bị ngắt quãng, như hai bên của một khe khuyết.", + "help.sketch.constraint.concentric": "Cho hai đường tròn hoặc cung cùng một tâm. Dùng cho vòng, lỗ khoét bậc và trục có vai.", + "help.sketch.constraint.curvature": "Làm hai đường cong gặp nhau tại đầu mút tiếp tục với cùng độ cong (G2). Dùng khi nối tiếp tuyến chưa đủ mượt, chẳng hạn trên các bề mặt nhìn thấy.", + "help.sketch.constraint.equal": "Làm các đường thẳng bằng chiều dài, hoặc các đường tròn và cung bằng bán kính. Dùng để một kích thước điều khiển nhiều chi tiết giống nhau.", + "help.sketch.constraint.fix": "Cố định điểm hoặc đường cong tại chỗ, hoặc bỏ cố định chúng. Dùng cho hình học không được di chuyển khi bạn kéo hoặc giải phác thảo.", + "help.sketch.constraint.horizontal_vertical": "Làm một đường thẳng, hoặc đường nối hai điểm, nằm ngang hoặc thẳng đứng, tùy hướng nào gần hơn. Dùng để căn đường bao thẳng theo các trục phác thảo.", + "help.sketch.constraint.midpoint": "Đặt một điểm vào trung điểm của đường thẳng, hoặc làm trung điểm của hai đường thẳng trùng nhau. Dùng để căn giữa hình học trên một cạnh.", + "help.sketch.constraint.parallel": "Làm hai đường thẳng song song. Dùng cho các cạnh phải luôn song song khi các kích thước khác thay đổi.", + "help.sketch.constraint.perpendicular": "Làm hai đường thẳng gặp nhau ở góc vuông. Dùng cho các góc vuông giữa những đường không nằm trên trục phác thảo.", + "help.sketch.constraint.polygon": "Biến chuỗi đường thẳng khép kín thành đa giác đều: các cạnh bằng nhau, mọi đỉnh nằm trên một đường tròn. Dùng để làm đa giác vẽ tay thành đa giác đều và giữ nguyên như vậy.", + "help.sketch.constraint.symmetry": "Làm hai điểm hoặc đường cong đối xứng nhau qua một đường thẳng hoặc quanh một điểm. Dùng để giữ biên dạng đối xứng khi bạn sửa một bên.", + "help.sketch.constraint.tangent": "Làm hai đường cong tiếp xúc tiếp tuyến, như đường thẳng và cung. Dùng cho các chỗ nối mượt, không bị gãy góc.", + "help.sketch.construction": "Chuyển đường cong giữa hình học thường và hình học dựng hình, thứ mà biên dạng bỏ qua. Dùng đường dựng hình làm công cụ hỗ trợ bố trí, tham chiếu và ghi kích thước.", "help.sketch.construction_line": "Tạo đường thẳng dựng hình vô hạn qua hai điểm để bố trí phác thảo mà không chia tách biên dạng.", "help.sketch.construction_line.angle": "Tạo đường dựng hình vô hạn theo góc so với trục X của phác thảo. Chọn điểm rồi nhập góc hoặc chọn hướng.", "help.sketch.construction_line.bisector": "Chọn hai đường thẳng giao nhau trong phác thảo rồi chọn một trong hai đường phân giác. Kết quả thay đổi theo các đường gốc.", @@ -1415,12 +1571,71 @@ "help.sketch.construction_line.tangent_external": "Chọn đường tròn hoặc cung tròn, một điểm ngoài rồi chọn nghiệm tiếp tuyến. Bản xem trước chỉ hiển thị tiếp điểm trên cung đã vẽ.", "help.sketch.construction_line.tangent_two": "Chọn hai đường tròn hoặc cung tròn rồi chọn tiếp tuyến chung trong bản xem trước. Tiếp điểm phải nằm trên các đường cong đã vẽ.", "help.sketch.construction_line.vertical": "Tạo đường dựng hình thẳng đứng vô hạn qua một điểm. Ràng buộc đứng được giữ khi chỉnh sửa sau này.", + "help.sketch.copy": "Sao chép các đường cong và điểm phác thảo đã chọn, cùng các ràng buộc và kích thước giữa chúng. Dán vào phác thảo này hoặc phác thảo khác.", + "help.sketch.create": "Bắt đầu một phác thảo 2D mới trên mặt phẳng chuẩn hoặc mặt phẳng của khối. Mọi lệnh Đùn khối, Xoay tròn tạo khối và Quét theo đường dẫn đều bắt đầu từ một phác thảo.", + "help.sketch.cut": "Sao chép hình học phác thảo đã chọn rồi xóa khỏi phác thảo, trong một bước hoàn tác. Dán sang chỗ khác trong phác thảo này hoặc phác thảo khác.", + "help.sketch.dimension": "Thêm kích thước điều khiển: chiều dài, khoảng cách, góc, bán kính hoặc đường kính, tùy thứ bạn chọn. Nhập giá trị hoặc tên tham số, phác thảo sẽ thay đổi theo.", + "help.sketch.edit": "Mở một phác thảo có sẵn để chỉnh sửa. Các tính năng dựng trên nó sẽ cập nhật khi bạn hoàn tất phác thảo.", + "help.sketch.ellipse": "Vẽ hình elip từ tâm, đầu mút trục lớn và một điểm ở phía trục nhỏ. Dùng cho lỗ mở hình bầu dục, cam và các hình tròn dài khác.", "help.sketch.equation_curve": "Vẽ một đường cong từ công thức trên một khoảng: x(t) và y(t), hoặc y = f(x). Công thức có thể dùng tham số và đường cong thay đổi theo: dùng cho sóng sin, đường xoắn ốc hay đường thân khai của răng bánh răng.", + "help.sketch.extend": "Kéo dài đường thẳng hoặc cung đến đường cong gần nhất cản đường, hoặc kéo hai đường cong cho đến khi gặp nhau. Dùng để đóng khe hở trong biên dạng sau khi cắt tỉa hoặc chỉnh sửa.", + "help.sketch.fillet": "Bo tròn góc nơi hai đường thẳng hoặc cung gặp nhau bằng một cung có bán kính cho trước. Nhiều góc bo cùng lúc dùng chung một bán kính.", + "help.sketch.finish": "Thoát phác thảo và quay lại mô hình. Các biên dạng khép kín của nó khi đó sẵn sàng cho Đùn khối, Xoay tròn tạo khối và các tính năng khác.", + "help.sketch.finish_auto_constrain": "Chạy Tự động gán ràng buộc cho phác thảo rồi hoàn tất. Dùng để để lại phác thảo xác định hoàn toàn chỉ trong một bước.", + "help.sketch.fit_curves_to_section": "Cắt một lưới hoặc khối đặc bằng mặt phẳng phác thảo rồi khớp các đường thẳng và cung vào mặt cắt. Dùng để dựng lại chi tiết quét hoặc nhập vào thành phác thảo có thể chỉnh sửa.", + "help.sketch.include_3d": "Đưa cạnh, đỉnh của mô hình và đường cong của các phác thảo khác vào phác thảo này dưới dạng tham chiếu liên kết, làm phẳng phần nằm ngoài mặt phẳng của nó. Dùng để dựng tiếp trên hình học không nằm trong mặt phẳng phác thảo.", + "help.sketch.infinite": "Chuyển đường thẳng giữa đường thường và đường dựng hình vô hạn chạy suốt khung nhìn. Dùng làm tham chiếu bố trí không bao giờ bị ngắn.", + "help.sketch.insert_dxf": "Đọc các đường cong của bản vẽ DXF vào phác thảo, theo tỉ lệ, góc xoay và vị trí bạn đặt. Dùng để dựng tiếp từ bản vẽ 2D của chương trình khác, như biên dạng cắt laser.", + "help.sketch.insert_svg": "Đọc các đường dẫn của bản vẽ SVG vào phác thảo, theo tỉ lệ, góc xoay và vị trí bạn đặt. Dùng để biến logo hoặc đường bao véc tơ thành đường cong có thể đùn.", "help.sketch.inspect": "Mở báo cáo về bản phác thảo đang mở: còn bao nhiêu bậc tự do và đã ràng buộc hoàn toàn chưa. Dùng nó để biết phần nào vẫn còn di chuyển được trước khi hoàn tất bản phác thảo.", + "help.sketch.intersect": "Thêm các đường cong hoặc điểm nơi khối, mặt, cạnh hoặc phác thảo khác cắt qua mặt phẳng phác thảo. Chúng vẫn được liên kết và thay đổi theo mô hình.", + "help.sketch.intersection_curve": "Tạo đường cong 3D tại nơi hai khối hoặc hai mặt gặp nhau, liên kết với chúng. Dùng làm đường dẫn hoặc ray dẫn hướng thay đổi theo mô hình.", + "help.sketch.isoparametric_curve": "Tạo đường cong 3D theo hướng U hoặc V của một mặt qua điểm bạn chọn, liên kết với mặt đó. Dùng làm đường dẫn hoặc đường tách thay đổi theo bề mặt.", + "help.sketch.line": "Vẽ các đường thẳng nối liền từng điểm một, thành chuỗi hở hoặc đường bao khép kín. Đây là công cụ cơ bản cho hầu hết biên dạng.", + "help.sketch.line.midpoint": "Vẽ đường thẳng từ trung điểm ra một đầu, đầu kia đối xứng qua trung điểm. Dùng cho đường thẳng có tâm tại một điểm, như cạnh đối xứng.", + "help.sketch.line3d": "Vẽ các đường thẳng nối giữa các điểm trong không gian, khi bật 3D Phác thảo. Dùng cho đường dẫn và khung nằm ngoài mặt phẳng phác thảo.", + "help.sketch.mirror": "Lấy đối xứng gương các đường cong và điểm phác thảo qua một đường thẳng hoặc trục, đồng thời thêm ràng buộc đối xứng. Dùng để vẽ nửa biên dạng đối xứng rồi hoàn thiện nó.", + "help.sketch.move": "Di chuyển hoặc xoay các đường cong và điểm phác thảo, hoặc một bản sao của chúng. Dùng để đổi vị trí hình học mà vẫn giữ nguyên hình dạng và ràng buộc.", + "help.sketch.offset": "Tạo bản sao của một chuỗi đường cong ở khoảng cách cho trước, về một phía hoặc cả hai. Dùng cho thành, đường bao và khe hở; khoảng cách vẫn là kích thước có thể chỉnh sửa.", + "help.sketch.paste": "Dán hình học phác thảo đã sao chép hoặc cắt vào phác thảo đang hoạt động. Đặt tại một điểm hoặc dời theo một khoảng.", + "help.sketch.pattern.circular": "Sao chép hình học phác thảo quanh một tâm, trên cả vòng tròn hoặc một góc cho trước. Dùng cho vòng lỗ bu lông, nan hoa và răng.", + "help.sketch.pattern.rectangular": "Sao chép hình học phác thảo theo hàng và cột với khoảng cách cho trước. Dùng cho lưới lỗ, rãnh then hoặc khe thông gió.", + "help.sketch.point": "Đặt một điểm phác thảo. Dùng làm tham chiếu cho kích thước, ràng buộc, tâm lỗ hoặc các tính năng sau này.", + "help.sketch.point3d": "Đặt một điểm trong không gian, khi bật 3D Phác thảo. Dùng làm tham chiếu cho đường cong và đường dẫn 3D.", + "help.sketch.polygon.circumscribed": "Vẽ đa giác đều bao quanh một đường tròn, kích thước tính từ tâm đến trung điểm một cạnh. Dùng khi biết khoảng cách giữa hai cạnh đối diện, như với đai ốc lục giác.", + "help.sketch.polygon.edge": "Vẽ đa giác đều từ hai đầu mút của một cạnh. Dùng khi biết chiều dài và vị trí của cạnh.", + "help.sketch.polygon.inscribed": "Vẽ đa giác đều nội tiếp trong một đường tròn, kích thước tính từ tâm đến một đỉnh. Dùng khi biết kích thước giữa hai đỉnh đối diện.", + "help.sketch.project": "Đưa cạnh, mặt, đỉnh của mô hình và đường cong của các phác thảo khác vào phác thảo này, chiếu lên mặt phẳng của nó. Chúng vẫn được liên kết và thay đổi theo mô hình.", + "help.sketch.project_to_surface": "Chiếu đường cong của các phác thảo khác theo pháp tuyến của phác thảo này lên một mặt, thành đường cong 3D liên kết. Dùng để đặt logo, đường tách hoặc đường dẫn lên bề mặt cong.", + "help.sketch.rectangle.center": "Vẽ hình chữ nhật từ tâm và một góc. Dùng cho đường bao có tâm tại một điểm, như gốc tọa độ.", + "help.sketch.rectangle.rounded": "Vẽ hình chữ nhật có cả bốn góc bo tròn cùng một bán kính. Dùng cho tấm, nắp và lỗ mở có góc bo tròn.", + "help.sketch.rectangle.three_point": "Vẽ hình chữ nhật từ một cạnh và một điểm ở phía đối diện. Dùng cho hình chữ nhật nghiêng so với trục phác thảo.", + "help.sketch.rectangle.two_point": "Vẽ hình chữ nhật từ hai góc đối diện. Dùng cho đường bao của tấm, khối và hốc.", + "help.sketch.scale": "Thay đổi tỉ lệ các đường cong và điểm phác thảo theo một hệ số quanh điểm gốc. Dùng để đổi kích thước một phần phác thảo mà không phải vẽ lại.", + "help.sketch.slot.arc_center": "Vẽ rãnh then cong có độ rộng cho trước dọc theo một cung xác định bởi tâm, điểm đầu và điểm cuối. Dùng cho rãnh điều chỉnh quanh trục xoay.", + "help.sketch.slot.arc_three_point": "Vẽ rãnh then cong có độ rộng cho trước dọc theo một cung đi qua ba điểm. Dùng khi đường đi của rãnh phải qua các điểm cho trước.", + "help.sketch.slot.center_point": "Vẽ rãnh then thẳng có độ rộng cho trước từ tâm của nó đến tâm cung ở một đầu. Dùng cho rãnh có tâm tại một điểm.", + "help.sketch.slot.center_to_center": "Vẽ rãnh then thẳng có độ rộng cho trước giữa tâm của hai cung ở hai đầu. Dùng cho rãnh ghi kích thước tâm đến tâm, như rãnh điều chỉnh vít.", + "help.sketch.slot.overall": "Vẽ rãnh then thẳng có độ rộng cho trước từ mép ngoài này đến mép ngoài kia. Dùng khi chiều dài toàn phần của rãnh là điều quan trọng.", + "help.sketch.spline.control_point": "Vẽ spline bậc ba được định hình bởi các điểm điều khiển mà nó không đi qua. Dùng cho đường cong mượt, uyển chuyển mà bạn chỉnh bằng cách di chuyển các điểm.", + "help.sketch.spline.control_point_5": "Vẽ spline bậc 5 được định hình bởi các điểm điều khiển. Dùng khi các đường cong cần nối với độ cong mượt hơn, chẳng hạn trên bề mặt tạo dáng nhìn thấy.", + "help.sketch.spline.fit_point": "Vẽ spline mượt đi qua mọi điểm bạn đặt, hở hoặc khép kín. Dùng để vẽ theo một hình dạng qua các điểm đã biết.", + "help.sketch.spline3d": "Vẽ đường cong mượt trong không gian qua các điểm, khi bật 3D Phác thảo. Dùng cho đường dẫn của lệnh Quét theo đường dẫn và đường đi dây cáp nằm ngoài mặt phẳng phác thảo.", + "help.sketch.spun_profile": "Tạo trong mặt phẳng phác thảo đường bao mà một khối quét qua khi quay quanh một trục. Dùng để lấy hình bóng của chi tiết tiện cho lệnh Xoay tròn tạo khối hoặc bản vẽ.", + "help.sketch.text": "Viết văn bản trong phác thảo với phông chữ, chiều cao và góc tùy chọn; nội dung có thể thay đổi theo tham số. Dùng Đùn khối hoặc Dập nổi để làm nhãn và chữ.", + "help.sketch.transform": "Đổi tỉ lệ, xoay và di chuyển hình học phác thảo quanh một tâm chỉ trong một bước. Dùng để đổi vị trí và kích thước của vùng chọn cùng lúc.", + "help.sketch.trim": "Xóa đoạn đường cong nằm giữa các giao điểm gần nhất quanh chỗ bạn nhấp. Dùng để dọn các đường chồng lên nhau thành biên dạng khép kín.", "help.solid.align": "Di chuyển các khối đã chọn để một điểm, mặt hoặc mặt phẳng trên chúng gặp một điểm, mặt hoặc mặt phẳng ở nơi khác. Dùng để đặt chi tiết áp vào chi tiết khác mà không cần đo khoảng cách.", + "help.solid.boundary_fill": "Tạo khối đặc từ các vùng được bao kín bởi các thân khối công cụ chồng lấn, chỉ điền đầy những vùng bạn chọn. Dùng để dựng hình từ không gian được nhiều thân khối cùng bao lại.", + "help.solid.bounding_solid": "Tạo khối hộp bao ngoài các thân khối đã chọn, có thể thêm khoảng dư. Dùng để xem kích thước tổng thể của chi tiết hoặc làm phôi để gia công chi tiết đó.", + "help.solid.box": "Tạo khối hộp theo chiều dài, chiều rộng và chiều cao, từ điểm góc hoặc có tâm tại một điểm. Dùng để tạo nhanh khối, hình giữ chỗ hoặc thân khối để cắt tạo các tính năng.", "help.solid.chamfer": "Vát các cạnh đã chọn theo khoảng cách bạn thiết lập. Dùng để triệt tiêu cạnh sắc hoặc giúp lắp chốt, bu lông dễ dàng hơn.", + "help.solid.coil": "Tạo thân khối xoắn ốc, như lò xo, từ đường kính, số vòng, chiều cao hoặc bước xoắn và tiết diện tròn hoặc vuông. Dùng cho lò xo, cuộn xoắn và gờ xoắn ốc.", "help.solid.combine": "Hợp nhất các khối, cắt trừ khỏi khối đích, hoặc chỉ giữ lại phần thể tích giao nhau, đồng thời có thể giữ lại các khối công cụ. Dùng để gộp chi tiết, dùng hình này cắt hình kia hoặc tìm vị trí hai khối giao nhau.", + "help.solid.cone": "Tạo khối nón từ đường tròn đáy và chiều cao, hoặc nón cụt khi đỉnh cũng có bán kính. Dùng cho phần thuôn, phễu, lỗ loe côn và đầu nhọn.", + "help.solid.cylinder": "Tạo khối trụ theo bán kính hoặc đường kính và chiều cao, dựng tại một điểm dọc theo một trục. Dùng cho trục, chốt, vấu trụ và phôi tròn.", "help.solid.draft": "Làm nghiêng các mặt đã chọn theo góc thiết lập, đo từ một mặt phẳng trung hòa. Dùng cho chi tiết đúc hoặc ép phun để chúng tháo ra khỏi khuôn được.", + "help.solid.emboss": "Dập nổi các biên dạng phác thảo, như chữ hoặc logo, lên khỏi mặt hoặc dập chìm vào mặt. Dùng cho chữ, nhãn và dấu trên mặt phẳng hoặc mặt tròn.", "help.solid.extrude": "Đùn biên dạng phác thảo thành khối 3D theo khoảng cách, thêm hoặc bớt vật liệu. Dùng để biến phác thảo phẳng thành khối, vấu nổi hoặc hốc lõm.", "help.solid.fillet": "Bo tròn các cạnh đã chọn bằng cung cong mượt mà theo bán kính thiết lập. Dùng để làm mềm cạnh sắc nhằm tăng độ bền, an toàn khi chạm hoặc tăng thẩm mỹ.", "help.solid.hole": "Khoan lỗ tròn theo đường kính thiết lập tại vị trí chọn trên mặt. Dùng cho lỗ xỏ bu lông, chốt định vị và các lỗ tròn đơn giản.", @@ -1429,20 +1644,60 @@ "help.solid.move": "Di chuyển hoặc xoay các khối đã chọn, hoặc một bản sao của chúng, theo khoảng cách và góc thiết lập. Dùng để đổi vị trí một khối hoặc tạo thêm một khối ở chỗ khác.", "help.solid.offset_face": "Đẩy các mặt đã chọn vào trong hoặc ra ngoài theo khoảng cách thiết lập, các mặt xung quanh tự điều chỉnh theo. Dùng để làm thành dày hơn, hốc sâu hơn hoặc phần lồi cao hơn mà không sửa bản phác thảo.", "help.solid.pattern.circular": "Lặp lại các đối tượng được chọn quanh một trục theo số lượng và tổng góc thiết lập. Dùng cho bố trí vòng bu lông, nan hoa, cánh tản nhiệt và răng bánh răng.", + "help.solid.pattern.path": "Sao chép tính năng hoặc thân khối dọc theo đường phác thảo, theo số lượng và khoảng cách. Dùng cho các lỗ dọc theo cạnh cong hoặc chi tiết dọc theo thanh ray.", "help.solid.pattern.rectangular": "Lặp lại các đối tượng được chọn theo một hoặc hai hướng với số lượng và khoảng cách thiết lập. Dùng cho dãy hoặc mảng lỗ, gân tăng cứng, chốt định vị hoặc chân đế.", + "help.solid.pipe": "Tạo ống tròn dọc theo đường dẫn gồm các đường cong phác thảo, đặc hoặc rỗng với độ dày thành. Dùng cho ống, dây điện, cáp và thanh ray.", "help.solid.press_pull": "Thêm hoặc bớt vật liệu khi kéo một mặt, hoặc bo tròn các cạnh khi kéo chúng. Dùng để sửa nhanh trực tiếp trên mô hình.", "help.solid.remove": "Loại các khối đã chọn khỏi thiết kế từ điểm này trong lịch sử trở đi; các bước trước vẫn giữ chúng. Dùng để bỏ một khối phụ trợ, như một công cụ cắt không còn cần.", "help.solid.replace_face": "Đưa các mặt phẳng đã chọn tới một mặt phẳng hoặc một mặt song song, khối sẽ dài ra hoặc ngắn lại để chạm tới đó. Dùng để làm một mặt phẳng hàng với chi tiết khác hoặc với mặt phẳng dựng hình.", "help.solid.revolve": "Quay biên dạng phác thảo quanh một trục để tạo khối tròn xoay. Dùng cho trục, núm xoay, chén bát và bất kỳ chi tiết nào tròn xoay quanh tâm.", + "help.solid.rib": "Thêm gân tăng cứng mỏng dọc theo đường cong phác thảo hở, kéo đầy tới các mặt kế tiếp của thân khối. Dùng để tăng cứng thành và góc, chẳng hạn trong chi tiết đúc hoặc in.", "help.solid.scale": "Phóng to hoặc thu nhỏ các khối đã chọn theo một hệ số, như nhau theo mọi hướng hoặc riêng cho từng trục. Dùng để đổi kích thước hình nhập vào hoặc bù độ co ngót.", "help.solid.shell": "Khoét rỗng khối đặc, để lại thành mỏng theo độ dày thiết lập, và có thể mở các mặt bạn chọn. Dùng cho hộp, nắp che và các chi tiết thành mỏng.", + "help.solid.sphere": "Tạo khối cầu theo bán kính hoặc đường kính quanh một điểm tâm. Dùng cho bi, núm và đầu bo tròn.", "help.solid.split_body": "Tách một khối thành các khối riêng bằng một mặt phẳng, mặt của khối khác hoặc một đường thẳng trong bản phác thảo. Dùng để tạo các chi tiết được in, gia công hoặc tô màu riêng.", "help.solid.split_face": "Chia các mặt đã chọn theo một mặt phẳng, mặt của khối khác hoặc các đường cong phác thảo, mà không tách khối. Dùng để cho một phần của mặt có độ nghiêng, màu hoặc độ dịch riêng.", "help.solid.sweep": "Quét một biên dạng phác thảo dọc theo đường dẫn để tạo khối đặc. Dùng cho đường ống, thanh ray, tay vịn và bất kỳ chi tiết nào có tiết diện không đổi chạy dọc theo đường cong.", + "help.solid.thread": "Thêm ren hệ mét ISO lên mặt trụ, dưới dạng ren biểu diễn hoặc cắt vào thân khối. Dùng cho bu lông, lỗ ren và các chi tiết ren được in.", + "help.solid.torus": "Tạo khối vòng từ bán kính lớn quanh trục và bán kính nhỏ của tiết diện tròn. Dùng cho vòng đệm kín, tay cầm và gờ tròn dạng vòng.", + "help.solid.web": "Thêm thành mỏng dọc theo mỗi đường cong phác thảo hở, mỗi thành kéo đầy tới các mặt kế tiếp của thân khối. Dùng để tạo mạng lưới gân đỡ bên trong vỏ hộp và khay.", + "help.surface.blend": "Tạo mặt cong nối các cạnh của hai thân khối, tiếp tuyến hoặc liên tục độ cong với cả hai. Dùng để nối hai mặt cong mượt qua một khoảng hở.", + "help.surface.boundary": "Tạo mặt cong lấp đầy một vòng gồm hai đến bốn đường cong phác thảo, hoặc một mạng đường cong. Dùng cho các tấm tự do được xác định bởi các cạnh của chúng.", + "help.surface.extend": "Kéo dài mặt cong ra ngoài cạnh của nó, theo khoảng cách hoặc đến một mặt phẳng hoặc mặt. Dùng để các mặt cong chồng lên nhau trước khi Cắt tỉa hoặc Khâu mặt cong (Stitch).", + "help.surface.extrude": "Đùn các đường cong hoặc biên dạng phác thảo theo một khoảng cách thành mặt cong, chỉ có thành, không có nắp. Dùng cho thành mỏng và làm dụng cụ cắt hoặc tách.", + "help.surface.fair": "Làm mượt phần bên trong của các mặt tự do trong khi cạnh của chúng giữ nguyên. Dùng để san phẳng các chỗ lồi lõm và phản chiếu gợn sóng trên bề mặt.", + "help.surface.loft": "Tạo mặt cong đi qua hai hoặc nhiều tiết diện theo thứ tự, không có nắp. Dùng cho vỏ mỏng và chuyển tiếp giữa các hình dạng khác nhau.", + "help.surface.match": "Dựng lại cạnh của một mặt cong để gặp cạnh của thân khối khác, khớp tiếp tuyến hoặc độ cong. Dùng để khép kín đường nối mượt giữa hai mặt cong.", + "help.surface.offset": "Tạo bản sao của các mặt cách một khoảng theo pháp tuyến, thành một thân mặt cong mới. Dùng để tạo lớp vỏ, khe hở hoặc mặt bên kia của một thành.", + "help.surface.patch": "Lấp một vùng phác thảo hoặc vòng cạnh kín bằng một mặt, phẳng hoặc cong mượt. Dùng để bít các lỗ trên thân mặt cong.", + "help.surface.reverse_normal": "Lật các mặt của thân mặt cong để phía ngoài của chúng quay sang hướng ngược lại. Dùng trước khi Tạo độ dày cho mặt cong hoặc Khâu mặt cong (Stitch) khi các mặt đang hướng vào trong.", + "help.surface.revolve": "Xoay các đường cong hoặc biên dạng phác thảo quanh một trục thành mặt cong, không có nắp. Dùng cho vỏ tròn như chụp đèn, bát và vòi phun.", + "help.surface.ruled": "Tạo một dải đường thẳng xuất phát từ chuỗi cạnh, theo pháp tuyến, tiếp tuyến của mặt hoặc theo một góc. Dùng cho thành thoát khuôn, vùng thoát và mặt phân khuôn.", + "help.surface.stitch": "Khâu các thân mặt cong lại với nhau dọc theo cạnh chung, thành khối đặc nếu chúng khép kín. Dùng để biến một tập hợp mặt cong thành một thân khối.", + "help.surface.sweep": "Di chuyển các đường cong hoặc biên dạng phác thảo dọc theo đường dẫn để tạo mặt cong, không có nắp. Dùng cho máng xối, nẹp viền và các hình mỏng khác chạy theo đường dẫn.", + "help.surface.thicken": "Cho thân mặt cong một độ dày, biến nó thành khối đặc. Dùng để tạo chi tiết in được hoặc gia công được từ các mặt cong.", + "help.surface.trim": "Cắt một mặt cong bằng mặt phẳng, mặt cong khác hoặc đường cong phác thảo, và giữ lại phía bạn chọn. Dùng để cắt tỉa các mặt cong theo hình trước khi Khâu mặt cong (Stitch).", + "help.surface.unstitch": "Tách các mặt của một thân khối thành những thân mặt cong riêng; khối đặc sẽ rã ra thành các mặt cong. Dùng để chỉnh lại từng mặt riêng của một thân khối.", + "help.surface.untrim": "Loại bỏ các lỗ khỏi một mặt, hoặc khôi phục biên ngoài của nó về giới hạn tự nhiên của mặt cong. Dùng để lấy lại toàn bộ mặt cong sau khi Cắt tỉa.", "help.third_party": "Giấy phép của bên thứ ba", "help.timeline.compute_all": "Dựng lại mọi tính năng trong lịch sử từ đầu. Dùng để làm mới mô hình và xem tính năng nào bị lỗi.", "help.timeline.delete": "Xóa các tính năng đã chọn khỏi lịch sử; xóa một bản phác thảo cũng xóa các tính năng dựng từ nó. Dùng để bỏ một bước không còn cần.", + "help.timeline.dependents": "Liệt kê các tính năng sẽ bị xóa cùng một tính năng, và những tính năng sẽ lỗi nếu thiếu nó. Dùng trước khi xóa hoặc thay đổi một tính năng.", "help.timeline.play": "Phát lại lần lượt các tính năng còn lại, tạm dừng nửa giây sau mỗi lần dựng lại. Chạy lại lệnh này hoặc nhấn Escape để dừng; Đến cuối sẽ chuyển ngay đến cuối lịch sử.", + "help.view.apply": "Xoay khung nhìn về hướng chuẩn như trước, trên hoặc iso, hoặc về một góc nhìn đã lưu. Dùng để xem mô hình từ một góc cố định.", + "help.view.delete": "Xóa một góc nhìn đã lưu. Mô hình không bị thay đổi.", + "help.view.fit": "Phóng to và dịch khung nhìn để toàn bộ mô hình, hoặc phần đang chọn, lấp đầy khung nhìn. Dùng khi mô hình đã trôi ra khỏi tầm mắt.", + "help.view.grid": "Hiện hoặc ẩn lưới trong khung nhìn. Dùng làm thước tham chiếu kích thước và hướng khi dựng mô hình.", + "help.view.ground_shadow": "Hiện hoặc ẩn bóng của mô hình trên mặt sàn. Dùng để đánh giá độ cao và vị trí dễ hơn.", + "help.view.home": "Đưa khung nhìn về hướng mặc định và vừa khít mô hình. Dùng để quay lại nhanh một góc nhìn quen thuộc.", + "help.view.list": "Liệt kê các góc nhìn đã lưu trong thiết kế. Dùng để tìm góc nhìn bạn đã lưu trước đó.", + "help.view.navigate": "Xoay, dịch hoặc thu phóng khung nhìn theo từng bước, chẳng hạn xoay một phần tư vòng sang trái. Dùng để điều khiển camera chính xác thay vì kéo chuột.", + "help.view.navigation_bar": "Hiện hoặc ẩn thanh điều hướng ở cuối khung nhìn. Ẩn đi để có thêm chỗ, hiện ra để dùng các công cụ xem bằng chuột.", + "help.view.rename": "Đặt tên mới cho một góc nhìn đã lưu. Bản thân góc nhìn không đổi.", + "help.view.save": "Lưu vị trí camera hiện tại thành một góc nhìn có tên trong thiết kế. Dùng để quay lại đúng góc đó sau này, chẳng hạn khi chụp ảnh màn hình.", + "help.view.spin": "Quay khung nhìn quanh mô hình liên tục cho đến cú nhấp hoặc phím tiếp theo. Dùng để trình bày mô hình từ mọi phía.", + "help.view.tool": "Giữ cố định một công cụ camera (xoay quanh, dịch hoặc thu phóng) để kéo bằng nút chuột trái sẽ di chuyển khung nhìn cho đến khi bạn tắt nó. Dùng trên bàn di chuột hoặc khi không có nút chuột giữa.", + "help.view.visual_style": "Đổi cách vẽ mô hình: tô bóng có hoặc không có cạnh, khung dây, hoặc hiện cạnh khuất. Dùng khung dây để nhìn vào bên trong, tô bóng để có hình ảnh gọn gàng.", "history.sketch_under_constrained": "Thiếu ràng buộc ({count} bậc tự do)", "history.sketch_under_constrained_one": "Thiếu ràng buộc ({count} bậc tự do)", "hole_presets.confirm_delete": "Xác nhận xóa", @@ -1656,6 +1911,7 @@ "nav.hfss.help": "Alt+kéo chuột trái để trượt, Shift+Alt+chuột trái để thu phóng, Ctrl+kéo chuột trái từ vùng trống để xoay", "nav.inventor.help": "Kéo chuột giữa để trượt, Shift + chuột giữa để xoay, Ctrl + chuột giữa để thu phóng, kéo chuột phải để xoay", "nav.maya.help": "Alt+kéo chuột trái để xoay, Alt+chuột giữa để trượt, Alt+chuột phải để thu phóng; kéo chuột giữa cũng trượt", + "nav.nx.help": "Kéo chuột giữa để xoay, Shift+chuột giữa để kéo ngang (NX: giữa + phải), Ctrl+chuột giữa để thu phóng (NX: giữa + trái); kéo chuột phải không di chuyển camera", "nav.openscad.help": "Kéo chuột trái trên vùng trống để xoay, Shift + chuột trái để quét chọn vùng, kéo hình học phác thảo để chỉnh sửa; kéo chuột phải hoặc chuột giữa để trượt, Shift + kéo chuột phải hoặc chuột giữa để thu phóng", "nav.perspective.sketch": "Chế độ chỉnh sửa phác thảo sử dụng góc nhìn trực giao. Hãy hoàn tất phác thảo để chuyển sang góc nhìn phối cảnh.", "nav.right_drag_orbits.help": "Tắt: kéo chuột phải không di chuyển camera, và nút phải chỉ mở menu ngữ cảnh. Chọn kiểu chuột SolidWorks sẽ tắt tùy chọn này.", @@ -1877,6 +2133,8 @@ "prefs.ctrl_cmd": "Ctrl / Cmd", "prefs.curved_surfaces": "Các mặt cong", "prefs.curves": "Các đường cong", + "prefs.dark_parts_shading": "Giữ hình dạng của chi tiết tối", + "prefs.dark_parts_shading_help": "Tô bóng khung nhìn bằng ánh sáng tuyến tính với ánh sáng bầu trời dịu, để các mặt tối giữ được dải chuyển màu thay vì thành màu đen", "prefs.dead_zone_note": "Dải sọc chéo là vùng chết: các chuyển động bên trong vùng này sẽ bị bỏ qua.", "prefs.default": "mặc định", "prefs.design": "Mặc định thiết kế", @@ -1891,6 +2149,8 @@ "prefs.edges": "Cạnh và đổ bóng", "prefs.extend": "Kéo dài", "prefs.extra_navigation": "Ctrl + kéo chuột trái trên vùng trống để xoay; giữ phím Cách (Space) để trượt góc nhìn.", + "prefs.extrude_view_isometric": "Xoay sang góc nhìn trục đo khi mở Đùn khối", + "prefs.extrude_view_return": "Trở lại góc nhìn trước khi đóng Đùn khối", "prefs.follows_os": "Theo hệ điều hành", "prefs.font": "Phông chữ", "prefs.font_fallback": "Các ký tự mà phông chữ đã chọn thiếu sẽ tự động dùng phông mặc định tích hợp, tránh tình trạng hiển thị ô vuông lỗi.", @@ -1906,6 +2166,7 @@ "prefs.graphics_card_auto": "Tự động", "prefs.graphics_card_missing": "Card đồ họa đã lưu không khả dụng; chế độ Tự động đang được sử dụng.", "prefs.graphics_card_restart": "Có hiệu lực sau khi khởi động lại SolidCraft. Biến môi trường WGPU_ADAPTER_NAME sẽ ghi đè lựa chọn này.", + "prefs.graphics_card_unused": "Card đồ họa đã lưu không được dùng cho cửa sổ này; card đang dùng hiện trong Trợ giúp › Giới thiệu và trên dòng trạng thái. Thử Tự động, hoặc đặt WGPU_ADAPTER_NAME thành phần tên card mang tính duy nhất.", "prefs.graphics_note": "Cân đối giữa tốc độ và độ mượt mà. Mỗi tùy chọn đều hiển thị hiệu ứng trực quan trên mẫu thử.", "prefs.graphics_presets_note": "Các cấu hình định sẵn sẽ thiết lập đồng thời các tùy chọn bên dưới.", "prefs.hide_constraint_glyphs_zoomed_out": "Ẩn biểu tượng ràng buộc khi thu nhỏ", @@ -1920,6 +2181,8 @@ "prefs.history_sketches_note": "Khi tùy chọn Phác thảo được đặt thành Dưới tính năng tương ứng, cây Lịch sử ở bên trái sẽ liệt kê mỗi phác thảo thụt vào một cấp dưới tính năng đầu tiên được tạo từ nó (khi khung đủ rộng để hiển thị tên). Dòng thời gian ở cạnh dưới luôn giữ nguyên thứ tự tạo.", "prefs.history_sketches_under_feature": "Dưới tính năng tương ứng", "prefs.hold_stop_snapping": "Giữ để tạm dừng bắt dính", + "prefs.home_fit_size": "Cỡ Home và Khớp", + "prefs.home_fit_size_note": "Mô hình chiếm bao nhiêu phần khung nhìn khi Home, Khớp, một thiết kế vừa mở hoặc đổi góc nhìn bằng khối điều hướng đưa nó vào khung hình, dù tùy chọn bên dưới bật hay tắt. 100 %: lấp đầy khung nhìn giữa các bảng, la bàn và thanh điều hướng.", "prefs.icons_only": "Chỉ hiện biểu tượng", "prefs.imported_file": "Tệp đã nhập", "prefs.in_browser": "Trong cây thư mục", @@ -1928,13 +2191,20 @@ "prefs.interface_language": "Ngôn ngữ giao diện", "prefs.interface_note": "Diện mạo của SolidCraft và cách bố trí các bảng điều khiển.", "prefs.interval_minutes": "{n} phút", + "prefs.invert_navigator_arrows": "Đảo ngược mũi tên xoay của khối điều hướng", + "prefs.invert_navigator_arrows_note": "Các mũi tên quanh khối xoay camera theo hướng mũi tên chỉ, như trong Fusion và Inventor: mũi tên bên trái chỉ sang phải và đưa camera sang phải. Khi tắt: mũi tên xoay khối về phía cạnh chứa nó, nên mũi tên bên trái đưa camera sang trái. Hai mũi tên cuộn cong không bị ảnh hưởng.", + "prefs.keep_work_on_tab_switch": "Giữ hộp thoại và công cụ mở khi chuyển giữa các thiết kế", + "prefs.keep_work_on_tab_switch_note": "Mỗi thẻ thiết kế giữ hộp thoại hoặc công cụ đang mở, cùng những gì bạn đã chọn và nhập trong đó, và thẻ thanh công cụ của nó. Chúng vẫn còn đó khi bạn quay lại thiết kế. Tắt: chuyển thiết kế sẽ hủy chúng và hiển thị thẻ Mô hình.", "prefs.labels": "Nhãn chữ", + "prefs.language_system": "Hệ thống", "prefs.layout": "Bố cục", "prefs.layout_note": "Chế độ Gọn (Compact) gắn các bảng vào mép cửa sổ. Độ trong suốt của Cây thư mục chỉ khả dụng ở chế độ Nổi (Floating). Thay đổi vị trí của Trợ lý trong trang Trợ lý.", "prefs.left": "Bên trái", "prefs.left_click": "Nhấp chuột trái", "prefs.line_drag_draws_one": "Nhấn và kéo vẽ một đường", "prefs.line_drag_draws_one_note": "Trong công cụ Đường, nhấn, kéo rồi thả sẽ vẽ một đường từ điểm nhấn đến điểm thả, và kết thúc ở đó. Nhấp vẫn vẽ một chuỗi. Tắt: kéo đặt một điểm tại nơi thả, như một lần nhấp.", + "prefs.line_hover_tangent_arc": "Dừng trên điểm cuối của đường để chuyển sang cung tiếp tuyến", + "prefs.line_hover_tangent_arc_note": "Trong công cụ Đường, sau khi vẽ một đoạn, hãy rời điểm cuối của chuỗi, quay lại và giữ yên con trỏ trên đó: lần nhấp tiếp theo sẽ kết thúc một cung tiếp tuyến, và cùng thao tác đó sẽ quay lại đường thẳng. Tắt: dừng ở đó không có tác dụng.", "prefs.linked_designs": "Thiết kế liên kết", "prefs.live_input": "Nhập liệu trực tiếp", "prefs.live_preview": "Xem trước trực tiếp", @@ -1958,6 +2228,8 @@ "prefs.mouse_mapping": "Gán chức năng chuột", "prefs.navigation": "Điều hướng góc nhìn", "prefs.navigation_note": "Cách chuột, bàn di cảm ứng và chuột 3D di chuyển góc nhìn.", + "prefs.navigator_fit": "Khớp mô hình sau khi đổi góc nhìn bằng khối điều hướng", + "prefs.navigator_fit_note": "Nhấp vào một mặt, góc hoặc mũi tên của khối lập phương, hoặc một trục của gizmo, cũng nhìn vào tâm mô hình từ một khoảng cách duy nhất chứa trọn nó từ mọi phía (khối cầu bao). Khi bật, góc nhìn chuẩn (Home) dùng cùng mức thu phóng và tâm đó, nên các góc nhìn chọn sau chỉ xoay. Tắt: góc nhìn xoay tại chỗ, giữ nguyên điểm nhìn và mức thu phóng.", "prefs.network_host_remove": "Xóa", "prefs.network_locations": "Cho phép các vị trí mạng trong thiết kế", "prefs.network_locations_note": "Khi tắt: các tệp liên kết của thiết kế trên máy tính khác (\\\\server\\share) chỉ được mở từ các máy chủ được liệt kê tại đây, hoặc sau khi bạn đồng ý khi được hỏi.", @@ -2006,6 +2278,8 @@ "prefs.render_previous_geometry_budget": "Dùng ngân sách hình học trước đây cho bản xem trước kết xuất", "prefs.render_previous_geometry_budget_help": "Khôi phục giới hạn hình học cố định trước đây (512 MiB trên máy tính 64 bit, 128 MiB trên các nền tảng khác) thay cho ngân sách dựa trên thiết bị.", "prefs.reports_saved": "Đã lưu {n}", + "prefs.ribbon_click_ends_tool": "Nhấp vào nút của công cụ đang chạy sẽ kết thúc nó", + "prefs.ribbon_click_ends_tool_note": "Nhấp vào nút ruy-băng đang sáng của công cụ phác thảo đang dùng sẽ kết thúc công cụ, để các cú nhấp trong khung nhìn lại chọn đối tượng. Tắt: cú nhấp khởi động lại công cụ.", "prefs.ribbon_reveal": "Hiện kết quả tìm kiếm trên ribbon", "prefs.ribbon_reveal_note": "Khi rê chuột lên một kết quả trong Tìm lệnh, ribbon cho biết lệnh nằm ở đâu: tab, nút hoặc dòng của nó trong menu thả xuống của một bảng. Lệnh bắt đầu từ tìm kiếm sẽ nhấp nháy ở đó, và lệnh đang chạy từ menu thả xuống sẽ đánh dấu bảng của nó.", "prefs.right_click": "Nhấp chuột phải", @@ -2035,6 +2309,8 @@ "prefs.sketch_exit_view.previous": "Trở về góc nhìn trước đó", "prefs.sketch_exit_view.unless_rotated": "Trở về góc nhìn trước trừ khi đã xoay", "prefs.sketch_exit_view_note": "Góc nhìn sẽ chuyển đến đâu sau khi bấm Hoàn tất phác thảo, Hủy bỏ hoặc bấm phím Esc hai lần.", + "prefs.sketch_face_activates_part": "Phác thảo trên mặt của một chi tiết sẽ kích hoạt chi tiết đó", + "prefs.sketch_face_activates_part_note": "Bản phác thảo bắt đầu trên mặt của một khối thuộc thành phần khác sẽ kích hoạt thành phần đó trước, nên bản phác thảo thuộc về nó và nằm cùng chi tiết đó trong trình duyệt. Chi tiết vẫn được kích hoạt sau đó. Tắt: bản phác thảo thuộc thành phần đang hoạt động.", "prefs.sketch_open_look_at": "Xoay góc nhìn trực diện phác thảo khi mở", "prefs.sketch_open_look_at_note": "Tắt: tạo hoặc chỉnh sửa phác thảo sẽ giữ nguyên góc nhìn. Dùng Nhìn trực diện để nhìn thẳng vào phác thảo khi cần.", "prefs.sketch_recent_snap": "Tự động bắt điểm vào tâm và trung điểm gần đây", @@ -2048,6 +2324,8 @@ "prefs.sketch_tab_always_note": "Thẻ Bản phác vẫn nằm trên thanh công cụ, ngay sau Mô hình, khi không có bản phác nào đang mở. Khi đó các công cụ vẽ của nó bắt đầu một bản phác trên mặt phẳng hoặc mặt đã chọn, hoặc trên mặt phẳng bạn chọn tiếp theo, với công cụ sẵn sàng trong đó.", "prefs.sketch_tracking_guides": "Đường căn chỉnh tâm và trung điểm", "prefs.sketch_tracking_guides_note": "Di con trỏ lên tâm đường tròn hoặc trung điểm của cạnh thẳng, rồi di chuyển theo phương ngang hoặc dọc để hiển thị đường căn chỉnh nét đứt tạm thời.", + "prefs.sketch_tracking_points": "Đường căn chỉnh từ điểm đầu mút và góc khi vẽ", + "prefs.sketch_tracking_points_note": "Khi một công cụ vẽ đang chờ một điểm, di con trỏ lên đầu mút của đường thẳng hoặc cung, góc của hình chữ nhật hoặc một điểm tự do sẽ hiển thị đường căn chỉnh nét đứt tạm thời giống như ở tâm hoặc trung điểm. Tắt: chỉ tâm và trung điểm.", "prefs.sketching": "Vẽ phác thảo", "prefs.snap_drawing_note": "Khi đang vẽ hoặc kéo một điểm phác thảo.", "prefs.source": "Nguồn", @@ -2064,6 +2342,24 @@ "prefs.status_error": "Lỗi", "prefs.status_selected": "Đã chọn", "prefs.status_warning": "Cảnh báo", + "prefs.structure.appearance": "Giao diện", + "prefs.structure.appearance_note": "Màu sắc, văn bản và khả năng tiếp cận.", + "prefs.structure.in_profile": "Trong hồ sơ", + "prefs.structure.layout": "Bố cục", + "prefs.structure.layout_note": "Vị trí các phần của cửa sổ.", + "prefs.structure.look": "Hiển thị", + "prefs.structure.measure_pointer": "Đo lường và con trỏ", + "prefs.structure.mouse3d": "Chuột 3D", + "prefs.structure.mouse3d_note": "SpaceMouse và SpaceNavigator.", + "prefs.structure.navigation_note": "Chuột và bàn di chuột. Chuột 3D có trang riêng.", + "prefs.structure.profile": "Hồ sơ", + "prefs.structure.profile_edited": "Đã thay đổi kể từ lần lưu", + "prefs.structure.profile_hover": "Được lưu trong hồ sơ cài đặt", + "prefs.structure.scale_percent": "{percent}%", + "prefs.structure.sketch": "Phác thảo", + "prefs.structure.tools": "Công cụ", + "prefs.structure.tools_note": "Cách các công cụ dựng mô hình hoạt động.", + "prefs.structure.work": "Công việc", "prefs.supersampling_brief": "Vẽ khung nhìn ở độ phân giải lớn hơn rồi lọc giảm tỷ lệ: rất sắc nét, nhưng tốn tài nguyên hơn.", "prefs.tab_accent_note": "Màu nhấn theo tab đang kích hoạt", "prefs.theme_file": "tệp chủ đề", @@ -2075,6 +2371,16 @@ "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_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ẻ", + "prefs.theme_option_tab_colours_help": "Sử dụng màu bảng điều khiển và khung nhìn của chủ đề này cho từng thẻ trên dải băng.", + "prefs.theme_option_tab_fade_ms": "Chuyển đổi thẻ", + "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_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", "prefs.theme_paste_none": "Bộ nhớ tạm không chứa giao diện SolidCraft nào", "prefs.timeline_half_hour": "−30 phút", @@ -2109,6 +2415,8 @@ "prefs.viewport_background_theme": "Dải màu của chủ đề", "prefs.viewport_section_status": "Hiện trạng thái mặt cắt trong khung nhìn", "prefs.viewport_section_status_help": "Hiện thiết lập mặt cắt trong khung nhìn. Escape và nút dải công cụ đang bật sẽ tắt mặt cắt. Tắt tùy chọn để giữ cách điều khiển mặt cắt trước đây.", + "prefs.viewport_status_details": "Chi tiết trực tiếp trong khung nhìn", + "prefs.viewport_status_details_help": "Chỉnh sửa thiết lập phân tích và mặt cắt đang hoạt động trong các thẻ chi tiết chuẩn. Tắt để khôi phục chú giải phân tích chỉ đọc và hộp thoại Mặt cắt.", "prefs.viewport_status_summary": "Tóm tắt các mục trạng thái khung nhìn", "prefs.viewport_status_summary_help": "Gộp từ ba mục trạng thái khung nhìn trở lên thành một huy hiệu tóm tắt. Tắt để giữ mọi mục trong hàng đầy đủ.", "prefs.wheel": "Con lăn chuột", @@ -3549,6 +3857,7 @@ "sketch_text.formula": "Bắt đầu bằng = để tạo chữ từ các tham số: ='W' + number(Width)", "sketch_text.italic": "In nghiêng", "sketch_text.loading_fonts": "Đang tìm kiếm các phông chữ đã cài đặt…", + "sketch_text.no_bold": "Phông chữ tích hợp không có kiểu in đậm: chọn một phông chữ đã cài đặt", "sketch_text.on_path": "Văn bản đi theo đường dẫn: đường cong định vị văn bản", "sketch_text.path_clear": "Gỡ văn bản khỏi đường cong dẫn", "sketch_text.path_hint": "Đường cong phác thảo mà văn bản chạy theo: nhấp vào đây, sau đó chọn đường cong", @@ -3757,6 +4066,7 @@ "ui.agent_chat_auth_terminal": "Chạy lệnh này trong cửa sổ dòng lệnh, sau đó nhấp Kết nối lại:", "ui.agent_chat_auth_title": "Đăng nhập vào AI Agent", "ui.agent_chat_cancel": "Hủy bỏ", + "ui.agent_chat_cannot_start": "Đã cài đặt nhưng không khởi động được: {error}", "ui.agent_chat_changes": "Thay đổi: {n}, trong 1 bước hoàn tác", "ui.agent_chat_changes_n": "{n} thay đổi", "ui.agent_chat_changes_of": "{n}/{limit} thay đổi", @@ -3849,6 +4159,7 @@ "ui.agent_chat_queued_stopped": "Đã xếp hàng (đã dừng, chưa gửi)", "ui.agent_chat_refresh": "Làm mới", "ui.agent_chat_refused": "Không được phép đối với tác tử: {reason}", + "ui.agent_chat_reinstall": "{agent} đã được cài đặt ({program}) nhưng không khởi động khi SolidCraft kiểm tra: {error}\nBản cài đặt có thể chưa hoàn tất, hoặc dành cho loại máy tính khác. Hãy cài lại bằng `{install}`, kiểm tra rằng nó in ra phiên bản trong terminal, rồi nhấn Làm mới.", "ui.agent_chat_remove": "Xóa khỏi hàng đợi", "ui.agent_chat_remove_file": "Bỏ tệp này khỏi tin nhắn", "ui.agent_chat_remove_imported": "Gỡ khỏi tin nhắn này; chi tiết vẫn nằm trong thiết kế (sử dụng Hoàn tác để xóa)", @@ -3912,12 +4223,17 @@ "ui.ai_copy_suffix": "sao chép", "ui.ai_custom_url": "URL tùy chỉnh", "ui.ai_default": "Mặc định", + "ui.ai_edit": "Sửa", + "ui.ai_edit_agent_title": "Sửa AI Agent", + "ui.ai_edit_gone": "AI Agent này đã bị xóa nên không có gì để lưu.", + "ui.ai_edit_tip": "Đổi tên, lệnh hoặc endpoint của AI Agent này. Khóa đã lưu vẫn được ẩn.", "ui.ai_http_busy": "điểm cuối đang bận hoặc vượt quá giới hạn; vui lòng thử lại sau", "ui.ai_http_key": "điểm cuối từ chối khóa API: hãy kiểm tra trong Tùy chọn cài đặt > AI", "ui.ai_http_not_found": "không tìm thấy điểm cuối hoặc mô hình: hãy kiểm tra URL cơ sở và tên mô hình", "ui.ai_http_too_large": "cuộc trò chuyện quá lớn đối với điểm cuối này; hãy bắt đầu cuộc trò chuyện mới", "ui.ai_key": "Khóa API", "ui.ai_key_hint": "tùy chọn; lưu trữ bảo mật trong chuỗi khóa hệ thống", + "ui.ai_key_keep_hint": "để trống để giữ khóa đã lưu", "ui.ai_key_needs_https": "Khóa chỉ được gửi qua HTTPS, hoặc trực tiếp trên máy tính này.", "ui.ai_key_note": "Được lưu an toàn trong Keychain hệ thống, không lưu trong cài đặt của SolidCraft. Để trống nếu dùng máy chủ cục bộ.", "ui.ai_keychain_error": "Chuỗi khóa hệ thống từ chối: {error}", @@ -3944,11 +4260,15 @@ "ui.ai_permissions": "Quyền hạn", "ui.ai_preset": "Mẫu cấu hình sẵn", "ui.ai_protocol": "Giao thức", + "ui.ai_reasoning_effort": "Mức độ suy luận", + "ui.ai_reasoning_effort_field_hint": "trống = mặc định của nhà cung cấp", + "ui.ai_reasoning_effort_hint": "Tùy chọn. Được gửi tới điểm cuối dưới dạng `reasoning_effort`; các giá trị được chấp nhận khác nhau tùy nhà cung cấp (ví dụ: `low`, `high` hoặc `max`). Giá trị bạn đặt ở đây không bao giờ bị SolidCraft thay thế. Để trống để không gửi gì và giữ mặc định của nhà cung cấp.", "ui.ai_remove": "Xóa", "ui.ai_request_timed_out": "Mô hình phản hồi lâu hơn {seconds} giây. Bạn có thể tăng thời gian chờ yêu cầu cho điểm cuối này trong Tùy chọn › Trợ lý.", "ui.ai_request_timeout": "Thời gian chờ yêu cầu", "ui.ai_request_timeout_hint": "Thời gian tối đa cho một yêu cầu tới điểm cuối này, từ 30 giây đến 1 giờ. Hãy tăng giá trị này đối với mô hình cục bộ xử lý chậm.", "ui.ai_reset": "Đặt lại", + "ui.ai_save": "Lưu", "ui.ai_settings": "AI cài đặt…", "ui.ai_show_features": "Hiển thị các tính năng AI", "ui.ai_show_features_note": "Trò chuyện với tác tử: bảng điều khiển nơi tác tử lập trình hoặc điểm cuối mô hình thao tác trên thiết kế đang mở. Tắt: ẩn nút AI, phím tắt, menu và bảng điều khiển, không tìm kiếm hay khởi chạy tác tử.", @@ -4108,6 +4428,7 @@ "ui.dark": "Tối", "ui.default_modelling_orientation": "Hướng mô hình hóa mặc định", "ui.delete": "Xóa", + "ui.delete_select_first": "Hãy chọn trước những gì cần xóa: tính năng, khối, thành phần hoặc hình học phác thảo", "ui.depth": "Độ sâu", "ui.design": "Thiết kế", "ui.design_properties.author": "Tác giả", @@ -4122,6 +4443,40 @@ "ui.design_properties.project": "Dự án", "ui.design_properties.revision": "Lần sửa đổi (Revision)", "ui.design_saved": "Đã lưu vào {path}", + "ui.details_access_caption": "Các bề mặt cho biết dụng cụ có thể tiếp cận chúng từ hướng đã chọn hay không.", + "ui.details_angle": "Góc", + "ui.details_continuity_caption": "Chỉnh sửa đường nối đã chọn. Các đường nối khác giữ nguyên thiết lập riêng.", + "ui.details_continuity_stale": "Hình học trước đó: chạy lại phân tích để chỉnh sửa đường nối này hoặc điều chỉnh khung nhìn vừa với nó.", + "ui.details_copy_list": "Sao chép danh sách", + "ui.details_curvature": "Dung sai độ cong", + "ui.details_curvature_caption": "Các bề mặt được tô màu theo bán kính cong cục bộ.", + "ui.details_direction": "Hướng dụng cụ", + "ui.details_draft_caption": "Các bề mặt được tô màu theo góc so với hướng tháo khuôn.", + "ui.details_edit_seam": "Chỉnh sửa đường nối", + "ui.details_fixture": "Mẫu thành phần giao diện — chỉ dùng dữ liệu minh họa", + "ui.details_fixture_caption": "Mẫu thành phần giao diện với các giá trị minh họa; đây không phải là kết quả của mô hình.", + "ui.details_fixture_problem": "Ví dụ kết quả có vấn đề", + "ui.details_fixture_readout": "Ví dụ giá trị đo", + "ui.details_flip": "Đảo hướng", + "ui.details_gap": "Khe hở", + "ui.details_hatch": "Gạch các mặt cắt", + "ui.details_jump": "Gián đoạn độ cong", + "ui.details_key": "Chú giải", + "ui.details_offset": "Độ lệch", + "ui.details_plane": "Mặt phẳng", + "ui.details_pull": "Hướng tháo khuôn", + "ui.details_radius": "Bán kính", + "ui.details_readout": "Giá trị đo", + "ui.details_samples": "Số mẫu", + "ui.details_seam": "{body} · Cạnh {edge}", + "ui.details_settings": "Thiết lập", + "ui.details_sharp_gap": "Sắc cạnh hoặc có khe hở", + "ui.details_show_all": "Hiển thị tất cả {count}", + "ui.details_smooth": "Trơn mượt", + "ui.details_stripes": "Sọc", + "ui.details_target": "Điều chỉnh khung nhìn vừa với hình học", + "ui.details_worst_seams": "Các đường nối kém nhất", + "ui.details_zebra_caption": "Các sọc phản chiếu cho thấy mức độ trơn mượt khi các bề mặt nối với nhau.", "ui.diameter": "Đường kính", "ui.dimension": "Kích thước", "ui.direct_interaction": "Tương tác trực tiếp", @@ -4166,6 +4521,7 @@ "ui.edit_in_place_saved_elsewhere": "Chi tiết đã được lưu vào tệp khác; cụm lắp ráp vẫn liên kết với {name}", "ui.edit_in_place_wait": "Hãy chờ những gì đang chạy trong thiết kế hoàn tất", "ui.edit_section_analysis": "Chỉnh sửa phân tích mặt cắt", + "ui.edit_sketch_empty": "Không có phác thảo để chỉnh sửa", "ui.ellipse": "Hình elip", "ui.emboss_wrap_note": "Các biên dạng đường trực giao tiếp xúc với một hình trụ được hỗ trợ sẽ tự động quấn. Chọn một thành để chọn mục tiêu.", "ui.end_radius": "Bán kính kết thúc", @@ -4176,12 +4532,15 @@ "ui.equation_explicit": "Tường minh", "ui.equation_form": "Dạng", "ui.equation_note": "Biến là một số không có đơn vị: sin(t) hiểu nó theo radian. Kết quả không có đơn vị dùng đơn vị của thiết kế.", + "ui.equation_note_range": "Biến là một độ dài, giống như khoảng của nó: có thể kết hợp với tham số độ dài, và chia cho một độ dài khi dùng sin(). Kết quả không có đơn vị sẽ theo đơn vị của thiết kế.", "ui.equation_parametric": "Tham số", + "ui.equation_variable_unit": "Biến có đơn vị của khoảng", "ui.equation_x_of_t": "x(t)", "ui.equation_y_of_t": "y(t)", "ui.equation_y_of_x": "y(x)", "ui.equivalent_radius": "Bán kính tương đương", "ui.expand": "Mở rộng", + "ui.explode_needs_two_components": "Chế độ xem phân rã tách các cụm chi tiết ra xa nhau, nên thiết kế cần có từ hai cụm chi tiết trở lên (Tạo mới Cụm chi tiết, Chèn thiết kế vào cụm hoặc Tạo cụm chi tiết từ các thân khối)", "ui.exportDxf": "Xuất tệp DXF…", "ui.export_ellipsis_keymap": "Xuất tệp…", "ui.export_keymap_tip": "Xuất phím tắt, danh sách S box yêu thích và thanh công cụ ra tệp", @@ -4350,12 +4709,14 @@ "ui.joints": "Các khớp nối", "ui.keyboard_shortcuts": "Phím tắt bàn phím", "ui.language": "Ngôn ngữ (Language)", + "ui.lasso_select": "Lasso (kéo một vòng tự do: chọn những gì nằm hoàn toàn bên trong)", "ui.layout_compact": "Gọn", "ui.layout_floating": "Nổi", "ui.length": "Chiều dài", "ui.length_decimals": "Số chữ số thập phân cho chiều dài", "ui.light": "Sáng", "ui.line": "Đoạn thẳng", + "ui.line_arc_hint": "Cung tiếp tuyến: nhấp vào điểm cuối của cung. Dừng lại trên điểm cuối cùng để quay lại đường thẳng; nhấp chuột phải hoặc Esc để kết thúc", "ui.link_copies": "Liên kết các bản sao đồng nhất", "ui.link_copies_keep": "Giữ nguyên", "ui.link_copies_link": "Tạo liên kết", @@ -4444,6 +4805,7 @@ "ui.part_size_shared": "Dựng lại chi tiết, thay thế mọi tính năng đã thêm. Tất cả các lần xuất hiện của chi tiết này đều được cập nhật.", "ui.paste": "Dán", "ui.path": "Đường dẫn", + "ui.pattern_object_type_tip": "Thân khối: các hình đặc, chọn trong khung nhìn hoặc Trình duyệt; mỗi thân được sao chép nguyên vẹn.\nTính năng: các bước trong Lịch sử, như một lần đùn khối hay một lỗ, chọn trong Lịch sử hoặc trên một mặt do chúng tạo ra; bước đó được lặp lại.\nBả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.", "ui.pattern_on_path": "Mảng dọc theo đường dẫn", "ui.perforate_margin_default": "theo đường kính", "ui.perforate_pitch": "Bước lỗ", @@ -4509,6 +4871,7 @@ "ui.properties.inertia_y": "Iyx, Iyy, Iyz", "ui.properties.inertia_z": "Izx, Izy, Izz", "ui.properties.measuring": "Đang đo…", + "ui.properties.measuring_bodies": "Đang đo… {done} trên {total} thân khối", "ui.properties.measuring_failed": "Phép đo đã dừng trước khi hoàn tất.", "ui.properties.principal_axis": "Trục chính {number}", "ui.properties.principal_moments": "Mômen quán tính chính", diff --git a/assets/locales/zh-TW.json b/assets/locales/zh-TW.json index 8bf7f0c3f9..d08d9b3c09 100644 --- a/assets/locales/zh-TW.json +++ b/assets/locales/zh-TW.json @@ -60,6 +60,7 @@ "BLEND SURFACE": "混合曲面", "BOUNDARY SURFACE": "邊界曲面", "BROWSER": "瀏覽器", + "Back to the original order": "還原原始順序", "Ball": "球", "Bar": "扁鋼", "Base Center": "底面中心", @@ -595,6 +596,8 @@ "Snap to face and edge centres": "吸附到面和邊的中心", "SolidCraft cannot submit reports directly. Submit on GitHub; no token is needed in the app.": "SolidCraft 無法直接提交報告。請在 GitHub 上提交;應用程式內不需要權杖。", "SolidCraft closed before these designs were saved. Their last autosave can be opened.": "SolidCraft 在這些設計儲存前已關閉。可以開啟它們的上次自動儲存。", + "Sort A to Z": "A 到 Z 排序", + "Sort Z to A": "Z 到 A 排序", "Spacing 2 must be a non-zero number": "間距 2 必須是非零數字", "Spacing must be a non-zero number": "間距必須是非零數字", "Specialise": "專用操作", @@ -819,6 +822,7 @@ "cmd.appearance_pick_up": "吸取外觀", "cmd.appearance_replace": "取代外觀", "cmd.assembly_bom": "物料清單", + "cmd.assembly_mass_properties": "質量屬性", "cmd.body.print_face": "列印底面", "cmd.body_rename": "重新命名實體", "cmd.browser_collapse_all": "全部摺疊", @@ -1244,6 +1248,7 @@ "cmd.view.render_browser": "彩現瀏覽器", "cmd.view.render_dock": "移動彩現停靠面板", "cmd.view.render_focus": "彩現聚焦檢視", + "cmd.view.status_details": "視埠狀態詳細資料", "cmd.view.status_items": "視埠狀態項目", "cmd.view_ambient_occlusion": "環境光遮蔽", "cmd.view_angle_readout": "檢視角度", @@ -1387,21 +1392,168 @@ "from the rule": "來自規則", "help.apache": "Apache 授權 2.0", "help.appearance.assign": "為所選的實體、面或元件設定顏色或材質外觀,不改變其重量。用於區分零件或預覽表面處理效果。", + "help.assembly.bom": "依零件編號列出設計或元件的零件及其數量,也可將清單儲存為 CSV 或 JSON。用作訂購與組裝的零件清單。", "help.attributions": "署名資訊 (ATTRIBUTION.md)", "help.build": "版本 {version} · 提交 {commit} · 建置日期 {date}", + "help.canvas.decal": "將圖片像貼紙一樣貼在模型的平坦面上。用於在零件上顯示標籤、標誌或印刷圖案。", + "help.canvas.edit": "變更畫布的位置、大小、角度、不透明度、鏡射、可見性或名稱。用於讓參考圖片對齊要描摹的草圖。", + "help.canvas.insert": "將 PNG 或 JPEG 圖片作為半透明參考放置在平面或面上。用於在草圖中描摹照片或圖紙,圖片可縮放至已知尺寸。", + "help.component.break_link": "將已連結的元件從其設計檔案斷開,並保持其實體原樣。用於零件不再需要跟隨原始檔案的變更時。", + "help.component.capture_position": "擷取手動移動後的零件位置,使其成為這些零件的所在位置。用於將零件拖曳到定位,且沒有接頭固定它們之後。", + "help.component.create": "在設計中新增一個空元件,預設位於作用中元件內,也可選擇其他父元件。用於為組合件的新零件提供存放實體與草圖的專屬位置。", "help.component.finish_edit": "儲存就地編輯的零件,返回其組合件,並以該零件更新組合件。連結零件的變更完成後使用。", + "help.component.from_bodies": "將每個所選實體轉成各自獨立的元件,並以該實體命名。用於將以零散實體建成的設計改為可接合、移動零件的組合件。", + "help.component.insert_design": "將另一個已儲存的設計作為連結到其檔案的元件插入,其實體維持原有的位置。用於在組合件中重複使用零件,並透過「更新連結」取得後續變更。", + "help.component.properties": "依元件實體的材質,顯示元件的體積、質量、質心與慣性矩。用於檢查組合件的重量與平衡。", + "help.component.revert_position": "將手動移動的零件放回原位,並捨棄尚未擷取的移動。用於還原組合件中的試移動。", + "help.component.update_links": "重新讀取已連結的設計檔案,並取代已變更的實體。用於編輯過此組合件所用的零件之後。", + "help.config.configure": "將參數、特徵、材質或外觀作為欄新增到組態表。用於建立同一設計的各種變體,例如零件的不同尺寸。", + "help.config.preview": "檢查組態表的某一列是否能重建,不更改設計或其歷程記錄。用於切換到沒有把握的變體之前。", + "help.config.rules": "顯示組態表的規則、各列,以及哪一列為作用中。用於了解設計的變體如何設定。", + "help.config.table": "顯示組態表:每個變體一列,每個參數或特徵一欄,並顯示設計是否仍與作用中的列相符。用於檢視設計的各個變體。", "help.connect_note": "請重新啟動各個助理以載入 SolidCraft 伺服器。每個檔案在變更前都已備份為 .bak。", "help.connect_title": "連接 AI 助理", + "help.construct.axis.edge": "沿直邊或草圖直線建立建構軸。用作旋轉、環形陣列或接頭的軸。", + "help.construct.axis.normal_to_face": "通過一點、垂直於面建立建構軸。用作從表面垂直伸出的軸。", + "help.construct.axis.through_cylinder": "通過圓柱面、圓錐面或圓環面的中心,或圓形邊,建立建構軸。用於讓特徵繞圓形零件旋轉或陣列。", + "help.construct.axis.two_planes": "在兩個平面或平坦的面相交處建立建構軸。用於所需的軸正是兩個面之間的交線時。", + "help.construct.axis.two_points": "通過兩點、頂點或草圖點建立建構軸。用於穿過兩個已知位置的軸。", + "help.construct.plane.along_path": "在邊或曲線上沿其某個位置,建立垂直於它的建構平面。用於為掃掠繪製輪廓草圖,或繪製路徑的剖面。", + "help.construct.plane.at_angle": "以某個軸或邊為基準,將平面或面旋轉一個角度,建立建構平面。用於傾斜的草圖,例如斜角支架。", + "help.construct.plane.midplane": "在兩個平面或平坦的面正中間建立建構平面;若兩者以一個角度相交,則平分該夾角。用作鏡射的對稱平面。", + "help.construct.plane.offset": "建立與平面或平坦的面平行的建構平面,可指定距離或通過一點。用於在面上方的指定高度繪製草圖。", + "help.construct.plane.perpendicular": "建立包含一條直線或邊、並垂直於某平面或面的建構平面。用於沿邊在模型上繪製直立的草圖。", + "help.construct.plane.tangent": "建立在某一點與圓柱、圓錐、球或圓環相切的建構平面。用於在圓形零件的側面繪製草圖。", + "help.construct.plane.three_points": "通過三點建立建構平面。用於由模型上的位置決定的平面,例如三個角。", + "help.construct.plane.two_edges": "通過位於同一平面內的兩條直邊或直線建立建構平面。用於橫跨零件的平面,例如通過兩條對邊。", + "help.construct.point.along_path": "在邊或曲線上沿其某個位置建立建構點。用作路徑中途的參考。", + "help.construct.point.center": "在圓形邊、草圖圓或圓弧、球或圓環的中心建立建構點。用作孔、軸和接頭的參考。", + "help.construct.point.edge_plane": "在邊或直線穿過平面或平坦的面之處建立建構點。用於標出路徑與平面相交的位置。", + "help.construct.point.three_planes": "在三個平面或平坦的面相交處建立建構點。用作角的參考,即使模型在該處沒有頂點。", + "help.construct.point.two_edges": "在兩條邊或直線相交或最接近之處建立建構點。用作虛擬角的參考。", + "help.construct.point.vertex": "在頂點或草圖點上建立建構點。用作參考,即使周圍的草圖或實體被隱藏,它也會保留。", + "help.contact.check": "列出接觸集中,在目前位置互相穿過的零件對。用於在驅動接頭或執行運動研究之前找出碰撞。", + "help.contact.create": "將兩個或多個零件組成一個接觸集,接頭移動時,其實體不會互相穿過。用於碰到就該停住的零件,例如槓桿與其擋塊。", + "help.contact.disable_all": "關閉接觸,使受驅動的接頭和運動研究不再於零件接觸處停止。用於讓零件重新自由移動,或讓大型組合件保持流暢。", + "help.contact.edit": "變更接觸集:其包含的零件、名稱,或是否抑制;也可以刪除該接觸集。用於調整哪些零件不可互相穿過。", + "help.contact.enable_all": "將組合件的所有零件放入同一個接觸集,使接頭移動時任何兩個零件都不會互相穿過。用於快速檢查整個機構。", + "help.contact.enable_sets": "為您定義的接觸集開啟接觸,使受驅動的接頭和運動研究在其零件即將互相穿過之處停止。用於關閉接觸後恢復您的接觸集。", + "help.contact.list": "列出設計的接觸集、各接觸集中的零件,以及接觸是否開啟。用於在驅動組合件之前確認接觸的設定。", + "help.document.inspect": "顯示設計的概覽:其元件、實體、草圖、特徵與參數。用於熟悉不是自己建立的設計。", + "help.document.units": "變更設計顯示長度所用的單位,例如 mm 或英吋。幾何尺寸不變,只有顯示的數值和未標單位的數字會跟著變。", + "help.edit.checkpoint": "記住設計、作用中的草圖與復原歷程目前的狀態。用於嘗試有風險、之後可能想退回的操作之前。", + "help.edit.restore_checkpoint": "回到某個檢查點,包括復原歷程。用於一步捨棄檢查點之後所做的一切。", + "help.explode.create": "建立爆炸圖,將零件從組合件中心向外推開,或依您設定的移動量推開。用於展示組合件如何組裝,供說明書或簡報使用。", "help.face.delete": "刪除所選的面,並由相鄰的面封閉留下的缺口,也可以讓缺口保持開放、成為曲面。用於移除礙事的圓角、孔或凸座。", + "help.file.insert_mesh": "將 3MF、STL、OBJ、PLY 或 glTF 檔案作為網格實體加入設計的新元件中。用於把下載或掃描的模型放在設計旁作為參考。", + "help.file.insert_step": "將 STEP、IGES、FreeCAD 或 Rhino 檔案作為實體加入設計的新元件中;組合件的零件會成為巢狀元件。用於匯入供應商或其他 CAD 程式的模型。", + "help.file.save_mesh": "將實體儲存為 STL、3MF 或 OBJ 網格,弧形面的平滑度可自行選擇。用於準備 3D 列印的零件,或供讀取網格的程式使用。", + "help.file.send_to_slicer": "將所選實體儲存為 3MF 檔案,並在您選擇的切片軟體中開啟。用於一步從模型進入列印。", "help.font_details": "介面使用 egui 的內建字型(Ubuntu Light 和 Hack,採用 Ubuntu Font Licence 和 MIT/Bitstream Vera 授權;Noto Emoji 和表情圖示字型,採用 OFL 和 MIT 授權)。草圖文字使用 Liberation Sans(SIL OFL 1.1)。The Crafting Apps 的可選字型包 craft-fonts(BIZ UDPGothic、Shippori Mincho、BIZ UDMincho、Noto Sans CJK SC、Noto Sans Arabic;SIL OFL 1.1)未隨 SolidCraft 建置版本隨附提供。", "help.fonts": "字型", + "help.frame.member": "沿框架草圖的直線放置輪廓(例如管材或鋁擠型材),每條直線一個構件。用於建立框架、機架和焊接結構。", + "help.inspect.accessibility": "為刀具能從某一方向到達的面和無法到達的面上色。用於檢查零件能否從該側進行加工或成型。", + "help.inspect.center_of_mass": "在均勻密度下,顯示各實體各自及全部合計的質心。用於檢查平衡以及零件會在哪裡傾倒。", + "help.inspect.colored_components": "為每個元件指定各自的暫時顏色,不改變外觀。用於一眼分辨組合件的各個零件。", + "help.inspect.compare": "比較兩個實體,並顯示其中一個有而另一個沒有材料之處,附帶體積與偏差。用於對照已變更或匯入的零件與原件。", + "help.inspect.continuity": "檢查面沿其邊相接的平滑程度:間隙、折角或曲率跳變,並為接縫著色。用於找出曲面接合不順之處。", + "help.inspect.curvature_comb": "沿曲線、邊或面繪製曲率梳,其梳齒顯示彎曲的程度。用於發現平坦處、折角和曲率不均。", + "help.inspect.curvature_map": "依彎曲程度為面上色,半徑小處為紅色。用於找出緊縮處並評估曲面的平滑度。", + "help.inspect.draft": "依面與拔模方向的拔模角為面上色,並標出角度不足者。用於成型或鑄造之前檢查零件。", + "help.inspect.environment_map": "以反光、鏡面般的表面顯示模型。用於從反射掠過的方式判斷曲面的走勢。", + "help.inspect.interference": "找出重疊的實體,並顯示每處重疊及其體積。用於檢查組合件中是否有零件互相碰撞。", + "help.inspect.isocurve": "繪製面的等參線,即曲面所依據的網格。用於觀察曲面如何走向,以及哪裡變得密集。", + "help.inspect.measure": "測量所選項目之間的長度、距離、角度、面積和半徑,或實體的體積和質量。用於檢查尺寸和間隙。", + "help.inspect.minimum_radius": "找出曲線、邊或面的最小曲率半徑及其位置。用於檢查刀具或噴嘴是否能跟隨該形狀。", + "help.inspect.section": "沿平面將檢視剖開並為切面畫上剖面線,不更改模型。用於查看零件和組合件的內部。", + "help.inspect.zebra": "在模型的面上顯示反射的黑白條紋。用於判斷平滑度:條紋在邊處斷開或折彎,表示過渡不佳。", + "help.joint.as_built": "在兩個零件現有的位置接合它們,不移動其中任何一個,並設定它們仍可如何移動。用於已放置在最終位置的零件。", + "help.joint.cam": "將旋轉的凸輪與從動件連結,使從動件隨凸輪輪廓經過其下方而滑動或擺動。用於凸輪、偏心輪和氣門驅動。", + "help.joint.create": "在您選取的接頭原點接合兩個零件,並將第二個零件移到定位;接頭類型決定它們仍可如何移動。用於建立零件像真實機構一樣運動的組合件。", + "help.joint.drive": "設定接頭的角度或距離,並在其限制內帶動零件移動;連動的接頭和封閉迴路會跟著動。用於檢查機構是否如預期運動。", + "help.joint.gear": "以齒輪比連結兩個旋轉接頭,驅動其中一個會帶動另一個。用於正齒輪和內齒輪,齒輪比取自節圓半徑或齒數。", + "help.joint.limits": "以最小值和最大值設定接頭可旋轉或滑動的範圍,或清除這些限制。用於讓鉸鏈、滑塊或槓桿保持在實際的行程範圍內。", + "help.joint.motion_link": "以比率和偏移量耦合兩個接頭,使驅動其一時另一個也隨之移動。用於「齒輪」、「齒輪齒條」或「螺旋」未涵蓋的接頭間固定關係。", + "help.joint.origin": "在零件上放置具名的接頭原點:位於面或圓端的中心、邊的中點、角或某一點。用於預先決定接頭的附著位置;在面上選取的原點會在零件改變大小時跟隨該面。", + "help.joint.rack_pinion": "將旋轉的小齒輪與滑動的齒條連結,使齒條移動的長度等於小齒輪滾過的節圓長度。用於齒條傳動、轉向機構和線性平台。", + "help.joint.reach": "透過在限制範圍內改變其鏈上的接頭,將零件上的一點移向目標。用於依末端應到達的位置擺放機構,例如機械手臂的尖端。", + "help.joint.rigid_group": "將所選零件按現狀鎖在一起,使它們一起移動。用於屬於同一組、彼此之間沒有運動的零件。", + "help.joint.screw": "以螺距連結旋轉與滑動,每轉一圈前進該距離。用於導螺桿、螺栓和螺紋軸。", + "help.library.apply": "將本機零件庫檔案中的材質或外觀套用到實體、面或元件。用於重複使用在其他設計中儲存的材質。", + "help.library.export": "將設計的材質和外觀儲存到零件庫檔案。用於分享,或在其他設計中重複使用。", + "help.library.inspect": "檢查零件庫檔案並列出其中的材質和外觀,不更改設計。用於套用之前查看零件庫包含什麼。", "help.licences": "授權", "help.material.assign": "設定所選實體的材質,例如鋼、鋁或 ABS。用於讓質量和重量與實際零件一致。", + "help.material.properties.validate": "檢查材質資料卡的數值是否一致、有無缺漏。用於在依賴這些資料計算質量或剛度之前。", + "help.mesh.convert": "將 STL 等網格實體重建為具有平面和圓柱面的可編輯實體。用於像修改建模的實體一樣修改匯入的機械零件。", "help.mit": "MIT 授權", + "help.motion.study": "在時間軸的各步驟設定接頭值,並以動畫播放。用於觀看機構依序運動,並沿途檢查間隙。", + "help.motion.trace": "描繪零件上某一點在運動研究播放或接頭被驅動時所走的路徑,確認後保留為 3D 草圖曲線。用於查看機構能到達哪裡,或以其路徑為基礎繼續建模。", "help.notice": "NOTICE", + "help.occurrence.ground": "將零件固定在原位,或再次釋放,讓接頭帶動其周圍的其他零件。用於作為組合件其餘部分基礎的框架或底座。", "help.occurrence.move": "僅沿接頭允許的自由度移動組合件零件。請選取零件的面,然後拖曳可用的箭頭或圓弧。", "help.parameters.change": "開啟用於驅動設計尺寸的具名數值與運算式表。用於在同一處修改尺寸,讓所有使用該尺寸的特徵隨之更新。", + "help.parameters.export": "將設計的參數儲存為 CSV 或 JSON。用於在試算表中編輯,或移到其他設計。", + "help.parameters.graph": "顯示哪些參數和特徵使用了哪些其他項目,以及其中的迴圈或錯誤。用於了解變更某個參數會影響什麼。", + "help.parameters.import": "將 CSV 或 JSON 中的參數讀入設計,全部匯入或全不匯入。用於從試算表或其他設計帶入數值。", + "help.parameters.move": "將使用者參數移到清單中另一個參數之前或之後。用於讓相關參數放在一起。", + "help.parts.fastener": "依您選擇的尺寸新增標準螺絲、螺栓、螺帽、墊圈、銷或軸承。放在孔上時,會以接頭落入孔中,因此無需自行建模扣件。", + "help.parts.insert": "依您選擇的系列、尺寸和長度插入標準零件。放在孔的邊緣或孔壁上時,會以剛性接頭落在孔上。", + "help.parts.library": "開啟標準零件:標準尺寸的螺絲、螺栓、螺帽、墊圈、止付螺絲、圓柱銷和軸承。用於新增標準零件,而無需自行建模。", + "help.parts.resize": "變更已插入標準零件的尺寸或長度並重建;其接頭使它保持就位。用於螺絲太長或孔需要另一種尺寸時。", + "help.plastic.assign_rule": "將塑膠規則指定給實體,使其塑膠特徵以該規則的拔模和間隙為預設值。用於一次為成型件的所有特徵完成設定。", + "help.plastic.boss": "在面上新增螺絲凸座:一根柱,可選孔、拔模、根部圓角和支撐肋。用於射出成型或列印外殼中的螺絲固定點。", + "help.plastic.lip": "沿薄殼實體的邊緣新增唇緣或對應的凹槽。用於讓外殼的兩半互相定位並緊密閉合。", + "help.plastic.manage_rules": "建立和編輯塑膠規則:材質、壁厚、半徑、間隙和拔模。用於讓成型件的各特徵保持一致。", + "help.plastic.rest": "在面上新增墊塊:圓形或矩形的墊,實心或空心,帶拔模。用作成型外殼中的支撐或間隔柱。", + "help.plastic.snap_fit": "在面上新增卡扣鉤,可設定長度、厚度和卡榫。用於不用螺絲就能把零件夾在一起。", "help.pointcloud.insert": "將 PLY、XYZ 或 PTS 掃描資料作為點雲插入模型旁。以實測資料建立模型時,可作為參考。", + "help.sheet.convert": "將厚度均勻的板或薄殼實體轉為鈑金實體,在您選取的邊處加入折彎。用於讓現有零件可以展開。", + "help.sheet.export_bend_table": "將鈑金實體的折彎表儲存為 CSV 或 JSON:展開狀態下的角度、半徑和折彎位置。用於折彎機或工廠。", + "help.sheet.export_dxf": "將展開圖儲存為 DXF,含輪廓、折彎線,以及可選的折彎註記。用於雷射、電漿或水刀切割。", + "help.sheet.flange": "新增凸緣:由草圖建立的底板、沿開放草圖輪廓的壁,或鈑金邊上的折彎壁。用於建立鈑金零件。", + "help.sheet.flat_pattern": "建立鈑金實體的展開圖,含其尺寸、切口和折彎線。用於切割之前檢查下料尺寸。", + "help.sheet.fold": "沿草圖直線以指定角度和半徑折彎鈑金。用於在平板上切出特徵後再將其摺疊。", + "help.sheet.hem": "將鈑金邊緣向內折回自身。用於得到安全、堅挺且沒有尖銳毛邊的邊緣。", + "help.sheet.manage_rules": "建立和編輯鈑金規則:厚度、折彎半徑、K 因子、止裂槽和間隙。用於配合您工廠的材料和工具。", + "help.sheet.path_flange": "沿鈑金邊新增一串凸緣,依循一串開放的草圖直線。用於一步做出 U 形或 Z 形等輪廓。", + "help.sheet.perforate": "在整片展開的鈑金上新增一片孔陣,採交錯或方形排列。用於通風口、格柵和減重面板。", + "help.sheet.refold": "將已展開的鈑金實體重新折起。用於在平板上加入特徵之後。", + "help.sheet.unfold": "展開鈑金實體的所有折彎。用於加入必須穿過折彎的孔或切口,之後再重新折彎。", + "help.sketch.arc.center_point": "繞圓心畫圓弧,從起點到終點。用於已知圓弧圓心時,例如繞孔或樞軸的半徑。", + "help.sketch.arc.tangent": "畫一段從直線或圓弧末端開始、並與其相切延續的圓弧。用於以平滑的彎曲而非轉角延續一串線條。", + "help.sketch.arc.three_point": "畫一段從起點經過第三點到終點的圓弧。用於圓弧必須通過給定的點,而非繞已知圓心時。", + "help.sketch.arc3d": "開啟「3D 草圖」時,通過三點在空間中畫出精確的圓弧。用於路徑和導軌中離開草圖平面的彎曲。", + "help.sketch.auto_constrain": "加入草圖幾乎已具備的拘束,例如水平、平行或重合,然後標註尺寸,直到完全定義。用於一步鎖定粗略畫出的草圖。", + "help.sketch.auto_constrain_from_datum": "與「自動拘束」相同,但從您選取的基準點而非原點量測位置。用於零件以某個參考點佈局時,例如角或孔。", + "help.sketch.blend_curve": "以與兩者相切的平滑雲形線連接兩條曲線的端點,若選擇,也可曲率連續。用於在曲線之間的缺口架橋而不產生折角。", + "help.sketch.break": "在其他曲線穿過處,或在您放置的點處,將曲線分割成多段。用於曲線的一部分需要自己的拘束,或需要單獨刪除時。", + "help.sketch.cancel": "離開草圖,並捨棄自建立或開啟編輯以來的所有變更。「復原」可找回這些變更。", + "help.sketch.centerline": "在一般直線與中心線之間切換,中心線是輪廓會略過的軸。可將中心線用作旋轉、鏡射或對稱拘束的軸。", + "help.sketch.chamfer.distance_angle": "為兩條直線之間的轉角倒斜角,由沿第一條線的距離和一個角度決定。用於圖紙以長度和角度給出倒角時,例如 2 mm × 30°。", + "help.sketch.chamfer.equal_distance": "沿兩條直線往回相同的距離,為兩條線之間的轉角倒斜角。用於輪廓上常見的 45° 倒角。", + "help.sketch.chamfer.two_distance": "為兩條直線之間的轉角倒斜角,沿每條線各有獨立的距離。用於非 45° 的倒角,以兩個長度給出。", + "help.sketch.circle.center": "以圓心和直徑畫圓。用於圓心已知的孔、凸座和其他圓形特徵。", + "help.sketch.circle.three_point": "通過三點畫圓。用於圓必須通過給定的點時,例如現有幾何上的點。", + "help.sketch.circle.three_tangent": "畫一個與三條直線、圓或圓弧相切的圓。用於讓圓落在三條曲線之間的空間內,例如三角形的內切圓。", + "help.sketch.circle.two_point": "以直徑的兩個端點畫圓。用於已知圓的邊緣點而非圓心時。", + "help.sketch.circle.two_tangent": "畫一個指定大小、與兩條直線、圓或圓弧相切的圓。用於兩條曲線之間固定半徑的圓角。", + "help.sketch.conic": "在兩個端點之間畫圓錐曲線,形狀由頂點和 rho 值決定。用於需要調整飽滿程度的平滑過渡,從平緩到近乎尖角。", + "help.sketch.constrainer": "為一條或兩條選取的曲線套用它們幾乎已具備的拘束,例如水平、平行、相切或相等。用於逐一整理手繪草圖的關係。", + "help.sketch.constraint.coincident": "使點落在另一個點、曲線或草圖軸上。用於封閉輪廓中的缺口,或將點放在直線上。", + "help.sketch.constraint.collinear": "使兩條直線位於同一條直線上。用於對齊中斷的邊,例如缺口的兩側。", + "help.sketch.constraint.concentric": "使兩個圓或圓弧具有相同的圓心。用於圓環、沉孔和階梯軸。", + "help.sketch.constraint.curvature": "使在端點相接的兩條曲線以相同的曲率(G2)延續。用於相切接合不夠平滑之處,例如在可見的曲面上。", + "help.sketch.constraint.equal": "使直線長度相同,或使圓與圓弧半徑相同。用於讓一個尺寸驅動多個相同的特徵。", + "help.sketch.constraint.fix": "將點或曲線固定在原位,或再次釋放。用於拖曳或求解草圖時不可移動的幾何。", + "help.sketch.constraint.horizontal_vertical": "使一條直線,或兩點之間的連線,成為水平或垂直,取較接近者。用於讓輪廓與草圖軸對齊。", + "help.sketch.constraint.midpoint": "將點放在直線的中間,或使兩條直線的中點重合。用於讓幾何居中於一條邊。", + "help.sketch.constraint.parallel": "使兩條直線平行。用於其他尺寸改變時必須保持平行的邊。", + "help.sketch.constraint.perpendicular": "使兩條直線以直角相交。用於不在草圖軸上的直線之間的方角。", + "help.sketch.constraint.polygon": "使一串封閉的直線成為正多邊形:各邊等長,每個角都在同一個圓上。用於讓手繪的多邊形變成正多邊形並保持如此。", + "help.sketch.constraint.symmetry": "使兩個點或曲線以一條直線為軸,或繞一點,互為鏡像。用於變更一側時保持輪廓對稱。", + "help.sketch.constraint.tangent": "使兩條曲線相切接觸,例如直線與圓弧。用於沒有折角的平滑接合。", + "help.sketch.construction": "在一般幾何與建構幾何之間切換曲線,輪廓會略過建構幾何。將建構曲線用作佈局、參考和尺寸的輔助。", "help.sketch.construction_line": "建立通過兩點的無限長建構直線。可用來規劃草圖佈局,而不會分割輪廓。", "help.sketch.construction_line.angle": "建立與草圖 X 軸夾指定角度的無限長建構直線。請選取點並輸入角度,或選取其方向。", "help.sketch.construction_line.bisector": "選取兩條相交的草圖直線,再選擇其中一條角平分線。結果會跟隨其來源直線。", @@ -1412,11 +1564,71 @@ "help.sketch.construction_line.tangent_external": "選取圓或圓弧,再選取外部點與一個切線解。預覽只會顯示位於所繪圓弧上的切點。", "help.sketch.construction_line.tangent_two": "選取兩個圓或圓弧,再於預覽中選擇一條公切線。切點必須位於所繪曲線上。", "help.sketch.construction_line.vertical": "建立通過某一點的鉛直無限長建構直線。其鉛直拘束會在後續編輯中持續維持。", + "help.sketch.copy": "複製所選的草圖曲線和點,連同它們之間的拘束與尺寸。可貼上到此草圖或另一個草圖。", + "help.sketch.create": "在平面或模型的平坦面上開始新的 2D 草圖。每個擠出、旋轉和掃掠都從草圖開始。", + "help.sketch.cut": "複製所選的草圖幾何並將其從草圖中刪除,作為一個復原步驟。可貼到此草圖的其他位置或另一個草圖。", + "help.sketch.dimension": "新增驅動尺寸:依所選項目為長度、距離、角度、半徑或直徑。輸入數值或參數名稱,草圖就會跟隨。", + "help.sketch.edit": "開啟現有的草圖進行編輯。完成草圖時,建立在其上的特徵會隨之更新。", + "help.sketch.ellipse": "由圓心、長軸末端和短軸側的一點畫橢圓。用於橢圓形開口、凸輪和其他細長的圓形形狀。", "help.sketch.equation_curve": "依公式繪製曲線:在指定範圍內使用 x(t) 和 y(t),或 y = f(x)。公式可以使用參數,曲線會隨之更新:可用於正弦波、螺旋線或齒輪齒形的漸開線。", + "help.sketch.extend": "將直線或圓弧延長到擋在前方的最近曲線,或讓兩條曲線生長直到相遇。用於修剪或編輯後封閉輪廓中的缺口。", + "help.sketch.fillet": "以指定半徑的圓弧,將兩條直線或圓弧相接的轉角倒圓。同時倒圓的多個轉角共用一個半徑。", + "help.sketch.finish": "離開草圖並回到模型。其封閉輪廓隨即可用於擠出、旋轉和其他特徵。", + "help.sketch.finish_auto_constrain": "對草圖執行「自動拘束」,然後完成草圖。用於一步讓草圖在完全定義的狀態下離開。", + "help.sketch.fit_curves_to_section": "以草圖平面剖切網格或實體,並將直線和圓弧擬合到剖面上。用於把掃描或匯入的零件重建為可編輯的草圖。", + "help.sketch.include_3d": "將模型的邊、頂點和其他草圖的曲線作為連結的參考帶入此草圖,並把不在其平面上的壓平到平面上。用於以不位於草圖平面內的幾何為基礎建構。", + "help.sketch.infinite": "在一般直線與橫貫整個檢視的無限長建構直線之間切換。用作永遠不會過短的佈局參考。", + "help.sketch.insert_dxf": "將 DXF 圖紙的曲線讀入草圖,並依您的設定縮放、旋轉和放置。用於以其他程式的 2D 圖紙為基礎建構,例如雷射切割的輪廓。", + "help.sketch.insert_svg": "將 SVG 圖形的路徑讀入草圖,並依您的設定縮放、旋轉和放置。用於把標誌或向量輪廓轉成可擠出的曲線。", + "help.sketch.inspect": "開啟目前草圖的報告:還剩多少個自由度,以及是否已完全拘束。用於在完成草圖之前找出哪些部分仍會移動。", + "help.sketch.intersect": "加入實體、面、邊或其他草圖與草圖平面相交的曲線或點。它們保持連結,並在模型變更時跟隨。", + "help.sketch.intersection_curve": "在兩個實體或面相接處建立 3D 曲線,並與它們連結。用作跟隨模型的路徑或導軌。", + "help.sketch.isoparametric_curve": "通過您選取的點,沿面的 U 或 V 方向建立 3D 曲線,並與該面連結。用作跟隨曲面的路徑或分割線。", + "help.sketch.line": "逐點畫出相連的直線,可為開放的鏈或封閉的輪廓。這是大多數輪廓的基本工具。", + "help.sketch.line.midpoint": "從中點向一端畫直線,另一端對稱映射。用於以一點為中心的直線,例如對稱的邊。", + "help.sketch.line3d": "開啟「3D 草圖」時,在空間中的點之間畫相連的直線。用於離開草圖平面的路徑和框架。", + "help.sketch.mirror": "以直線或軸為對稱軸鏡射草圖曲線和點,並加入對稱拘束。用於畫出對稱輪廓的一半後將其補全。", + "help.sketch.move": "移動或旋轉草圖曲線和點,或它們的副本。用於重新定位幾何,同時保持其形狀和拘束。", + "help.sketch.offset": "以指定距離建立一串曲線的副本,可在一側或兩側。用於壁、輪廓和間隙;距離保持為可編輯的尺寸。", + "help.sketch.paste": "將複製或剪下的草圖幾何貼到作用中的草圖。可放在某一點,或以偏移量移位。", + "help.sketch.pattern.circular": "繞圓心複製草圖幾何,可為整圈或指定角度。用於螺栓圓、輪輻和齒。", + "help.sketch.pattern.rectangular": "以指定間距按行和列複製草圖幾何。用於孔、槽或通風口的網格。", + "help.sketch.point": "放置草圖點。用作尺寸、拘束、孔中心或後續特徵的參考。", + "help.sketch.point3d": "開啟「3D 草圖」時,在空間中放置點。用作 3D 曲線和路徑的參考。", + "help.sketch.polygon.circumscribed": "在圓外畫正多邊形,大小由中心到邊中點的距離決定。用於已知對邊寬度時,例如六角螺帽。", + "help.sketch.polygon.edge": "由一條邊的兩個端點畫正多邊形。用於已知邊的長度和位置時。", + "help.sketch.polygon.inscribed": "在圓內畫正多邊形,大小由中心到角的距離決定。用於已知對角寬度時。", + "help.sketch.project": "將模型的邊、面、頂點和其他草圖的曲線投影到此草圖的平面上並帶入。它們保持連結,並在模型變更時跟隨。", + "help.sketch.project_to_surface": "沿此草圖的法向將其他草圖的曲線投影到面上,成為連結的 3D 曲線。用於把標誌、分割線或路徑放到曲面上。", + "help.sketch.rectangle.center": "由中心和一個角畫矩形。用於以一點為中心的輪廓,例如原點。", + "help.sketch.rectangle.rounded": "畫四個角都以同一半徑倒圓的矩形。用於帶圓角的板、蓋和開口。", + "help.sketch.rectangle.three_point": "由一條邊和對側的一點畫矩形。用於與草圖軸成一角度的矩形。", + "help.sketch.rectangle.two_point": "由兩個對角畫矩形。用於板、方塊和凹槽的輪廓。", + "help.sketch.scale": "以基點為中心按倍數縮放草圖曲線和點。用於調整草圖一部分的大小,而無需重畫。", + "help.sketch.slot.arc_center": "沿由圓心、起點和終點決定的圓弧,畫指定寬度的弧形槽。用於繞樞軸的調整槽。", + "help.sketch.slot.arc_three_point": "沿通過三點的圓弧畫指定寬度的弧形槽。用於槽的路徑必須通過給定的點時。", + "help.sketch.slot.center_point": "從中心到一端圓弧的中心,畫指定寬度的直槽。用於以一點為中心的槽。", + "help.sketch.slot.center_to_center": "在兩端圓弧的中心之間畫指定寬度的直槽。用於以中心距標註尺寸的槽,例如螺絲的調整槽。", + "help.sketch.slot.overall": "從一個外端到另一個外端畫指定寬度的直槽。用於槽的總長最重要時。", + "help.sketch.spline.control_point": "畫由控制點塑形的三次雲形線,曲線不會通過控制點。用於透過移動控制點來塑形的平滑流暢曲線。", + "help.sketch.spline.control_point_5": "畫由控制點塑形的 5 次雲形線。用於曲線須以更平滑的曲率接合之處,例如可見的造型曲面。", + "help.sketch.spline.fit_point": "畫通過您放置的每一點的平滑雲形線,可為開放或封閉。用於通過已知點描摹形狀。", + "help.sketch.spline3d": "開啟「3D 草圖」時,通過空間中的點畫平滑曲線。用於離開草圖平面的掃掠路徑和線纜路線。", + "help.sketch.spun_profile": "在草圖平面中建立實體繞軸旋轉時掃出的輪廓。用於取得車削零件的外形,供旋轉或圖紙使用。", + "help.sketch.text": "以選定的字型、高度和角度在草圖中寫入文字;文字內容可跟隨參數。可擠出或浮雕,用於標籤和字樣。", + "help.sketch.transform": "一步以中心點為基準縮放、旋轉和移動草圖幾何。用於一次重新定位並調整所選項目的大小。", + "help.sketch.trim": "移除曲線上您按一下之處兩側最近交點之間的一段。用於把重疊的線條清理成封閉的輪廓。", "help.solid.align": "移動所選實體,使其上的點、面或平面與別處的點、面或平面對齊。用於將一個零件貼靠到另一個零件上,而無需測量偏移量。", + "help.solid.boundary_fill": "由重疊的工具實體所圍成的區域建立實體,只填充您選取的區域。用於由多個實體共同圍出的空間建構形狀。", + "help.solid.bounding_solid": "建立包圍所選實體的長方體,可附邊距。用於查看零件的整體尺寸,或作為加工它的毛料。", + "help.solid.box": "由長、寬、高建立長方體,從一個角點開始或以一點為中心。用於快速建立方塊、佔位件,或作為要在其中切出特徵的實體。", "help.solid.chamfer": "以指定的距離在所選邊上切出平直的斜面。用於去除銳邊,或使銷釘、螺絲更容易插入。", + "help.solid.coil": "由直徑、圈數、高度或螺距,以及圓形或方形斷面,建立螺旋狀實體,例如彈簧。用於彈簧、線圈和螺旋脊。", "help.solid.combine": "結合多個實體、從目標實體中切除,或只保留它們共有的體積,還可以保留工具實體。用於合併零件、用一個形體切割另一個形體,或找出兩個實體重疊的位置。", + "help.solid.cone": "由底圓和高度建立圓錐體;若頂部也有半徑,則建立截頭圓錐。用於錐形、漏斗、錐孔和尖頭。", + "help.solid.cylinder": "由半徑或直徑和高度,沿軸立在一點上建立圓柱體。用於軸、銷、凸座和圓形毛料。", "help.solid.draft": "將所選的面依指定角度傾斜,角度從中性面量起。用於射出成型件或鑄件,使零件能夠順利脫模。", + "help.solid.emboss": "將草圖輪廓(例如文字或標誌)從面上凸起,或壓入面內。用於平面或圓面上的字樣、標籤和標記。", "help.solid.extrude": "將草圖輪廓依指定距離擠出為三維形體,可增加或移除材料。用於將平面輪廓變成方塊、凸座或凹槽。", "help.solid.fillet": "以指定半徑的平滑圓弧將所選的邊倒圓。用於為了強度、手感或外觀而柔化銳邊。", "help.solid.hole": "在面上的指定位置鑽出指定直徑的圓孔。用於螺絲過孔、銷孔及其他簡單的圓形開口。", @@ -1425,20 +1637,60 @@ "help.solid.move": "依指定的距離和角度移動或旋轉所選實體,或其副本。用於調整實體位置,或在別處再建立一個。", "help.solid.offset_face": "將所選的面依指定距離向內或向外推移,周圍的面隨之調整。用於在不編輯草圖的情況下加厚壁、加深凹槽或加高凸座。", "help.solid.pattern.circular": "將所選物件依指定的數量和總角度繞軸重複排列。用於螺栓圓周分布、輻條、散熱片和輪齒。", + "help.solid.pattern.path": "沿草圖路徑依數量和間距複製特徵或實體。用於沿曲邊排列的孔,或沿導軌排列的零件。", "help.solid.pattern.rectangular": "將所選物件依指定的數量和間距沿一個或兩個方向重複排列。用於成排或成網格的孔、肋、銷釘或支腳。", + "help.solid.pipe": "沿草圖曲線構成的路徑建立圓管,實心或帶壁厚的空心。用於管、線、纜和軌。", "help.solid.press_pull": "拖曳面以增加或移除材料,或拖曳邊將其倒圓。用於直接在模型上快速修改。", "help.solid.remove": "從時間軸的這個位置起,將所選實體從設計中移除;之前的步驟仍保留它們。用於移除輔助實體,例如不再需要的切割工具。", "help.solid.replace_face": "將所選的平面移到某個平面或平行的面上,實體會隨之伸長或縮短以對接。用於讓某個面與另一個零件或建構平面齊平。", "help.solid.revolve": "將草圖輪廓繞軸旋轉,生成圓形的實體。用於軸、旋鈕、碗,以及任何繞中心線呈圓形的零件。", + "help.solid.rib": "沿開放的草圖曲線新增薄壁,延伸填滿至實體的下一個面。用於加強壁和轉角,例如在成型或列印的零件中。", "help.solid.scale": "依比例放大或縮小所選實體,可各方向相同,也可依軸分別設定。用於調整匯入形體的大小,或預留收縮量。", "help.solid.shell": "將實體掏空,留下指定厚度的壁,還可以開啟所選的面。用於盒子、蓋子及其他薄壁零件。", + "help.solid.sphere": "由半徑或直徑繞中心點建立球體。用於球、旋鈕和圓端。", "help.solid.split_body": "以平面、另一個實體的面或草圖直線,將實體切分為多個獨立的實體。用於製作分別列印、加工或上色的零件。", "help.solid.split_face": "沿平面、另一個實體的面或草圖曲線分割所選的面,而不切開實體。用於為面的一部分單獨設定拔模、顏色或偏移。", "help.solid.sweep": "沿路徑拖曳草圖輪廓以生成實體。用於管道、導軌、把手,以及任何截面恆定且沿曲線延伸的零件。", + "help.solid.thread": "在圓柱面上新增 ISO 公制螺紋,可為裝飾螺紋或切入實體。用於螺栓、螺紋孔和列印的螺絲零件。", + "help.solid.torus": "由繞其軸的大半徑和圓形斷面的小半徑建立圓環體。用於 O 形環、把手和圓珠。", + "help.solid.web": "沿每條開放的草圖曲線新增薄壁,各自延伸填滿至實體的下一個面。用於外殼和托盤內部的支撐壁格柵。", + "help.surface.blend": "建立橋接兩個實體邊的曲面,與兩者相切或曲率連續。用於在間隙兩側平滑接合兩個曲面。", + "help.surface.boundary": "建立填滿由二至四條草圖曲線構成的迴圈或曲線網格的曲面。用於由邊界定義的自由形狀面板。", + "help.surface.extend": "將曲面延長超出其邊,可指定距離或延伸到平面或面。用於在修剪或縫合前讓曲面互相重疊。", + "help.surface.extrude": "將草圖曲線或輪廓沿一段距離推成曲面,只有壁,沒有封口。用於薄壁,以及切割或分割工具。", + "help.surface.fair": "在邊保持不動的情況下,平滑自由形狀面的內部。用於撫平曲面上的凸起和起伏的反射。", + "help.surface.loft": "建立依序通過兩個或多個斷面的曲面,沒有封口。用於殼體以及不同形狀之間的過渡。", + "help.surface.match": "重建曲面的邊,使其與另一個實體的邊相接,並相切或曲率相符。用於在兩個曲面之間平滑地封閉接縫。", + "help.surface.offset": "沿法線方向距離一定處建立面的副本,成為新的曲面實體。用於外皮、間隙,或壁的另一側。", + "help.surface.patch": "以平坦或平滑彎曲的面填滿草圖區域或封閉的邊迴圈。用於封閉曲面實體上的孔。", + "help.surface.reverse_normal": "翻轉曲面實體的面,使其外側朝向另一邊。用於面朝內時,在加厚或縫合之前。", + "help.surface.revolve": "將草圖曲線或輪廓繞軸旋轉成曲面,沒有封口。用於燈罩、碗和噴嘴等圓形殼體。", + "help.surface.ruled": "從一串邊沿面的法向、切線方向或以某個角度,建立由直線構成的條帶。用於拔模壁、延伸面和分模面。", + "help.surface.stitch": "沿共用的邊縫合曲面實體;若閉合則成為實體。用於將一組曲面變成一個實體。", + "help.surface.sweep": "將草圖曲線或輪廓沿路徑移動成曲面,沒有封口。用於沿路徑的排水槽、飾條和其他薄形狀。", + "help.surface.thicken": "賦予曲面實體厚度,將其變成實體。用於由曲面製作可列印或可加工的零件。", + "help.surface.trim": "以平面、另一個曲面或草圖曲線切割曲面,並保留您選取的一側。用於縫合前把曲面切成形狀。", + "help.surface.unstitch": "將實體的面分離為各自獨立的曲面實體;實體會散開成曲面。用於重做實體的單個面。", + "help.surface.untrim": "移除面上的孔,或將其外邊界還原為曲面的自然邊界。用於修剪後取回完整的曲面。", "help.third_party": "第三方授權", "help.timeline.compute_all": "從頭重新計算時間軸中的所有特徵。用於重新整理模型,並查看哪些特徵失敗。", "help.timeline.delete": "從時間軸中刪除所選特徵;刪除草圖時,由它建立的特徵也會一併刪除。用於移除不再需要的步驟。", + "help.timeline.dependents": "列出會隨某個特徵一起被刪除的特徵,以及沒有它就會失敗的特徵。用於刪除或變更特徵之前。", "help.timeline.play": "逐一重播其餘特徵,每次重建後暫停半秒。再次執行此命令或按 Escape 可停止;「移至結尾」會立即跳到結尾。", + "help.view.apply": "將檢視轉到標準方向(例如前、上或等角),或轉到已儲存的命名檢視。用於從固定角度觀看模型。", + "help.view.delete": "刪除已儲存的命名檢視。不會更改模型。", + "help.view.fit": "縮放並平移,使整個模型或所選項目填滿檢視。用於模型跑出視野之外時。", + "help.view.grid": "顯示或隱藏視埠中的網格。用作建模時的尺寸與方向參考。", + "help.view.ground_shadow": "顯示或隱藏模型在地面上的陰影。用於更容易判斷高度和位置。", + "help.view.home": "將檢視轉回主方向,並使模型符合視圖。用於快速回到已知的檢視。", + "help.view.list": "列出設計中儲存的命名檢視。用於找出先前儲存的檢視。", + "help.view.navigate": "以步進方式旋轉、平移或縮放檢視,例如向左四分之一圈。用於精確移動相機,而不必拖曳。", + "help.view.navigation_bar": "顯示或隱藏視埠底部的導覽列。隱藏可獲得更多空間,顯示則可用滑鼠使用檢視工具。", + "help.view.rename": "為已儲存的命名檢視取新名稱。檢視本身保持不變。", + "help.view.save": "將目前的相機位置儲存為設計中的命名檢視。用於之後回到同一角度,例如截圖。", + "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": "確認刪除", @@ -1615,6 +1867,10 @@ "material.selection.mixed": "不同材質", "measure.at": "位置", "measure.item": "項目 {number}", + "measure.quick_copied": "已複製測量值", + "measure.quick_copy": "按一下以複製測量值", + "measure.quick_edges": "{count} 條邊", + "measure.quick_faces": "{count} 個面", "messages.ago_h": "{n} 小時前", "messages.ago_min": "{n} 分鐘前", "messages.ago_s": "{n} 秒前", @@ -1767,6 +2023,11 @@ "not a move": "不是移動", "not a number": "不是數字", "open a sketch first": "請先開啟一個草圖", + "parameters.apply_live": "離開欄位時即套用每項變更", + "parameters.apply_live_tip": "關閉:變更會等到按下「套用」或「確定」,然後設計一次性為所有變更重建。「取消」或 Esc 會捨棄這些變更。", + "parameters.held": "{count} 項變更等待套用", + "parameters.held_hint": "編輯運算式和單位。變更會等到按下「套用」或「確定」。", + "parameters.held_one": "1 項變更等待套用", "parts.automatic_name": "自動", "parts.dimension.across_flats": "對邊寬度", "parts.dimension.bore": "內徑", @@ -1789,6 +2050,8 @@ "parts.family.set_screw": "內六角平端止付螺絲", "parts.family.socket_head_cap_screw": "內六角圓柱頭螺絲", "parts.family.washer": "平墊圈", + "parts.holes": "孔(可選)", + "parts.placement_hint": "選取一個或多個孔,將零件放入每個孔中,或按「確定」將其放在原點", "pattern.alignment_2": "對齊 2", "pattern.alignment_hint": "數量包含原始物件。置中時副本分佈在兩側;數量為偶數時,反轉可切換多一個副本的一側。", "pattern.direction_1": "方向 1", @@ -1891,6 +2154,7 @@ "prefs.graphics_card_auto": "自動", "prefs.graphics_card_missing": "已儲存的顯示卡無法使用;將改用自動選擇。", "prefs.graphics_card_restart": "重新啟動 SolidCraft 後生效。WGPU_ADAPTER_NAME 會覆蓋此選擇。", + "prefs.graphics_card_unused": "已儲存的顯示卡未用於此視窗;實際使用的顯示卡顯示在「說明 › 關於」和狀態列中。請嘗試「自動」,或將 WGPU_ADAPTER_NAME 設為顯示卡名稱中的唯一部分。", "prefs.graphics_note": "在速度與平滑度之間取捨。每個選項都會在範例上顯示其效果。", "prefs.graphics_presets_note": "預設集會一併設定下列選項。", "prefs.hide_constraint_glyphs_zoomed_out": "縮小檢視時隱藏拘束符號", @@ -1955,6 +2219,8 @@ "prefs.origin_plane_names": "原點平面名稱", "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.persistent_ribbon_pressed": "醒目顯示作用中的功能區命令", "prefs.persistent_ribbon_pressed_help": "在功能區命令的效果開啟期間醒目顯示這些命令。關閉可維持先前的中性外觀與命令說明。", "prefs.perspective_fov": "視角", @@ -1984,6 +2250,8 @@ "prefs.quality_fastest": "最快", "prefs.quality_slowest": "最慢", "prefs.quality_smoother": "更平滑", + "prefs.quick_measure": "檢視角落的快速測量", + "prefs.quick_measure_note": "在檢視右下角顯示所選項目的大小:邊的長度(圓形邊則為 Ø 和 R)、面的面積、點的位置、兩個項目的距離和角度,或多條邊、多個面的總計。按一下即可複製。", "prefs.render_previous_geometry_budget": "使用先前的彩現預覽幾何預算", "prefs.render_previous_geometry_budget_help": "還原先前固定的幾何上限(64 位元桌面版為 512 MiB,其他平台為 128 MiB),而非依裝置決定的預算。", "prefs.reports_saved": "已儲存 {n} 份", @@ -2029,6 +2297,8 @@ "prefs.sketch_tab_always_note": "沒有開啟草圖時,「草圖」索引標籤仍保留在功能區中,位於「模型」之後。使用其繪圖工具時,會在所選的平面或面上、或在你接著選取的平面上開始草圖,並直接啟用該工具。", "prefs.sketch_tracking_guides": "圓心與中點對齊輔助線", "prefs.sketch_tracking_guides_note": "游標停在圓心或直線邊的中點上,再水平或垂直移動,即可顯示暫時的點線輔助線。", + "prefs.sketch_tracking_points": "繪製時顯示來自端點和角的對齊輔助線", + "prefs.sketch_tracking_points_note": "繪圖工具等待指定點時,將游標停在直線或圓弧的端點、矩形的角或自由點上,會顯示與圓心或中點相同的暫時點線輔助線。關閉:僅限圓心和中點。", "prefs.sketching": "草圖", "prefs.snap_drawing_note": "繪製或拖曳草圖點時使用。", "prefs.source": "來源", @@ -2056,6 +2326,16 @@ "prefs.theme_follow_off": "關閉", "prefs.theme_follow_stop": "停止跟隨", "prefs.theme_import_hint": "按一下以選擇主題檔案、將檔案拖放到此處,或在已複製主題的情況下按 Ctrl+V(Mac 為 Cmd+V)", + "prefs.theme_option_opacity": "不透明度", + "prefs.theme_option_opacity_help": "調整浮動面板的不透明度。可讀性下限會保護視埠顏色上的文字。", + "prefs.theme_option_tab_colours": "索引標籤顏色", + "prefs.theme_option_tab_colours_help": "每個功能區索引標籤都使用此主題的面板和視埠顏色。", + "prefs.theme_option_tab_fade_ms": "索引標籤轉場", + "prefs.theme_option_tab_fade_ms_help": "切換索引標籤時,面板和視埠顏色會淡入淡出。設為零則立即切換。", + "prefs.theme_options_badge": "選項", + "prefs.theme_options_more": "更多 · {count}", + "prefs.theme_options_reset": "重設", + "prefs.theme_options_unavailable": "此裝置上無法使用", "prefs.theme_paste": "貼上主題", "prefs.theme_paste_none": "剪貼簿中沒有 SolidCraft 主題", "prefs.timeline_half_hour": "−30 分鐘", @@ -2077,6 +2357,8 @@ "prefs.under_cursor": "游標下方", "prefs.units_precision": "單位與精度", "prefs.updates": "更新", + "prefs.value_boxes_in_view": "在檢視中於控點旁顯示數值", + "prefs.value_boxes_in_view_tip": "命令的數值(距離、半徑…)會顯示在模型上方、控點旁的方框中。關閉:在命令視窗中輸入,選取後命令視窗會接手鍵盤,也不會有東西遮住模型。", "prefs.view": "檢視行為", "prefs.view_confirm_corner": "在檢視角落顯示「確定」與「取消」", "prefs.view_confirm_corner_note": "開啟對話框或草圖時,檢視右上角的勾號與叉號,作用等同於對話框的「確定」與「取消」,或完成與取消草圖。檢視導覽器會下移到它們下方。", @@ -2086,6 +2368,12 @@ "prefs.viewport_background": "背景", "prefs.viewport_background_colour": "顏色", "prefs.viewport_background_theme": "主題漸層", + "prefs.viewport_section_status": "顯示視埠剖面狀態", + "prefs.viewport_section_status_help": "在視埠中顯示剖面設定。按 Esc 或作用中的功能區按鈕可將其關閉。停用可保留先前的剖面控制項。", + "prefs.viewport_status_details": "即時視埠詳細資料", + "prefs.viewport_status_details_help": "在標準詳細資料卡中編輯作用中的分析和剖面設定。停用可還原唯讀的分析圖例和「剖面」對話方塊。", + "prefs.viewport_status_summary": "摺疊視埠狀態項目", + "prefs.viewport_status_summary_help": "將三個或更多視埠狀態項目摺疊成一個摘要徽章。停用可讓每個項目都留在完整的列中。", "prefs.wheel": "滾輪", "prefs.wheel_trackpad": "滾輪與觸控板", "prefs.workspace": "工作區", @@ -2964,7 +3252,9 @@ "render.output_ready": "可開始彩現", "render.output_run_check": "執行檢查", "render.output_size_limit": "{method}:每邊最多 {dimension} px,總計 {pixels} 像素。可容納的最大尺寸:{width} × {height}。", + "render.output_size_method_profile": "若要彩現 {width} × {height},請選擇搭配 {profile} CPU 資源設定檔的 {method},或選擇較小的尺寸。", "render.output_size_ok": "大小正常 · {width} × {height} px", + "render.output_size_profile": "若要彩現 {width} × {height},請選擇 {profile} CPU 資源設定檔,或選擇較小的尺寸。", "render.output_swap_orientation": "交換寬度與高度", "render.output_tools_inspector": "請在「輸出」檢查器中編輯尺寸與格式。", "render.output_view_image": "檢視影像", @@ -3177,6 +3467,7 @@ "render.size_custom": "自訂…", "render.size_full_hd": "Full HD", "render.size_hd": "HD", + "render.size_needs_profile": "{size} · 需要 {profile}", "render.size_presets": "尺寸預設", "render.size_presets_pending": "將隨「輸出」編輯器推出", "render.size_preview": "預覽", @@ -3464,6 +3755,24 @@ "sheet.flange_type_contour": "輪廓", "sheet.flange_type_edge": "邊線", "sheet.no_body": "沒有鈑金實體:請用「凸緣 > Base」建立,或用「轉換為鈑金」將板材轉為鈑金(選取其較大的平面)", + "sheet.personal_rules.confirm_delete": "確認刪除", + "sheet.personal_rules.delete": "刪除", + "sheet.personal_rules.delete_prompt": "要刪除此個人規則嗎?已在設計中的規則不受影響。", + "sheet.personal_rules.full": "個人規則庫已包含 64 條規則。", + "sheet.personal_rules.invalid_name": "請輸入不含換行或控制字元的簡短名稱。", + "sheet.personal_rules.invalid_values": "請補全規則的數值:每個值都必須能在此設計中成立,厚度須為正,K 因子須介於 0 到 1。", + "sheet.personal_rules.missing": "所選的個人規則已無法使用。", + "sheet.personal_rules.name": "規則名稱", + "sheet.personal_rules.name_exists": "已有同名的個人規則。請使用其他名稱。", + "sheet.personal_rules.name_hint": "唯一的名稱", + "sheet.personal_rules.overwrite": "將變更儲存到所選的個人規則", + "sheet.personal_rules.rename": "重新命名", + "sheet.personal_rules.rule": "個人規則", + "sheet.personal_rules.save": "另存為新規則", + "sheet.personal_rules.scope": "儲存在您的設定檔中,適用於所有設計。", + "sheet.personal_rules.select": "選擇規則", + "sheet.personal_rules.title": "個人規則", + "sheet.personal_rules.unsupported": "此規則含有此版本無法使用的值。", "shortcut.json_error": "參數必須採用 JSON 格式:{error}", "shortcut.no_command": "沒有命令 `{id}`;JSON 前的文字必須是命令 ID", "shortcut.pin": "固定到 S 框頂部", @@ -3500,6 +3809,7 @@ "sketch_text.align_right": "靠右對齊", "sketch_text.bold": "粗體", "sketch_text.builtin_font": "{family}(內建)", + "sketch_text.formula": "以 = 開頭,可由參數組出文字:='W' + number(Width)", "sketch_text.italic": "斜體", "sketch_text.loading_fonts": "正在查詢已安裝字型…", "sketch_text.on_path": "文字沿路徑排列:由曲線放置", @@ -3640,6 +3950,8 @@ "ui.3d_mouse_section": "3D 滑鼠", "ui.3d_mouse_show_in_view": "在 3D 檢視中顯示 3D 滑鼠", "ui.3d_mouse_show_in_view_tip": "在左下角顯示 3D 滑鼠的圖示,隨您的操作而移動。", + "ui.3d_mouse_sketch_orbit": "使用 3D 滑鼠旋轉草圖", + "ui.3d_mouse_sketch_orbit_tip": "開啟草圖且正對觀看時,3D 滑鼠也能像滑鼠一樣旋轉檢視。關閉:在您傾斜檢視之前,它只會平移和縮放草圖。", "ui.3d_mouse_skip": "我的裝置做不到", "ui.3d_mouse_skip_tip": "兩個方向都跳過此動作,校準時不包含它。", "ui.3d_mouse_speed": "3D 滑鼠速度", @@ -3708,6 +4020,7 @@ "ui.agent_chat_auth_terminal": "在終端中執行以下命令,然後再次點擊連線:", "ui.agent_chat_auth_title": "登入代理", "ui.agent_chat_cancel": "取消", + "ui.agent_chat_cannot_start": "已安裝,但無法啟動:{error}", "ui.agent_chat_changes": "{n} 項變更,一步復原", "ui.agent_chat_changes_n": "{n} 項變更", "ui.agent_chat_changes_of": "變更 {n}/{limit}", @@ -3800,9 +4113,12 @@ "ui.agent_chat_queued_stopped": "已加入佇列(已停止,未傳送)", "ui.agent_chat_refresh": "重新整理", "ui.agent_chat_refused": "不允許代理執行:{reason}", + "ui.agent_chat_reinstall": "{agent} 已安裝({program}),但 SolidCraft 檢查時它沒有啟動:{error}\n其安裝可能不完整,或是為另一種電腦製作的。請用 `{install}` 重新安裝,在終端機中確認它會印出版本,然後按「重新整理」。", "ui.agent_chat_remove": "從佇列中移除", "ui.agent_chat_remove_file": "從這條訊息中移除", "ui.agent_chat_remove_imported": "從這條訊息中移除;零件仍保留在設計中(復原可將其刪除)", + "ui.agent_chat_reply_cut_off": "回覆在完成前就達到長度上限,因此可能不完整。請要求代理繼續,或給出較短的答案。", + "ui.agent_chat_reply_declined": "模型拒絕回答此訊息。", "ui.agent_chat_retry": "重試", "ui.agent_chat_right": "右側", "ui.agent_chat_save": "儲存", @@ -3861,12 +4177,17 @@ "ui.ai_copy_suffix": "副本", "ui.ai_custom_url": "自訂 URL", "ui.ai_default": "預設", + "ui.ai_edit": "編輯", + "ui.ai_edit_agent_title": "編輯代理", + "ui.ai_edit_gone": "此代理已被移除,因此沒有可儲存的內容。", + "ui.ai_edit_tip": "變更此代理的名稱、命令或端點。已儲存的金鑰會保持隱藏。", "ui.ai_http_busy": "端點繁忙或已達上限,請稍後再試", "ui.ai_http_key": "端點拒絕了金鑰:請在「偏好設定 > AI」中檢查", "ui.ai_http_not_found": "找不到該端點或模型:請檢查基礎 URL 和模型名稱", "ui.ai_http_too_large": "對話對此端點來說過大;請開始新的聊天", "ui.ai_key": "API 金鑰", "ui.ai_key_hint": "可選;儲存在系統鑰匙圈中", + "ui.ai_key_keep_hint": "留空以保留已儲存的金鑰", "ui.ai_key_needs_https": "金鑰只會透過 HTTPS 傳送,或傳送到本機。", "ui.ai_key_note": "儲存在系統鑰匙圈中,絕不儲存在 SolidCraft 的設定裡。本機伺服器可留空。", "ui.ai_keychain_error": "系統鑰匙圈拒絕:{error}", @@ -3898,6 +4219,7 @@ "ui.ai_request_timeout": "請求逾時", "ui.ai_request_timeout_hint": "對此端點的單次模型請求最多可花費的時間,從 30 s 到 1 小時。本機模型較慢時請調高。", "ui.ai_reset": "重設", + "ui.ai_save": "儲存", "ui.ai_settings": "AI 設定…", "ui.ai_show_features": "顯示 AI 功能", "ui.ai_show_features_note": "代理聊天:一個面板,由您已在使用的程式設計代理或您選擇的模型端點處理開啟的設計。關閉後:沒有 AI 按鈕、快捷鍵、選單項或面板,也不會查詢或啟動任何程式。", @@ -4071,6 +4393,40 @@ "ui.design_properties.project": "專案", "ui.design_properties.revision": "修訂版本", "ui.design_saved": "已儲存至 {path}", + "ui.details_access_caption": "面會顯示刀具能否從所選方向到達。", + "ui.details_angle": "角度", + "ui.details_continuity_caption": "編輯所選的接縫。其他接縫保留各自的設定。", + "ui.details_continuity_stale": "較早的幾何:請重新執行分析,才能編輯此接縫或縮放至該處。", + "ui.details_copy_list": "複製清單", + "ui.details_curvature": "曲率公差", + "ui.details_curvature_caption": "面依其局部曲率半徑上色。", + "ui.details_direction": "刀具方向", + "ui.details_draft_caption": "面依其與拔模方向的夾角上色。", + "ui.details_edit_seam": "編輯接縫", + "ui.details_fixture": "元件樣本——僅為範例資料", + "ui.details_fixture_caption": "帶有示意數值的元件樣本;這不是模型的結果。", + "ui.details_fixture_problem": "有問題的範例發現", + "ui.details_fixture_readout": "範例測量值", + "ui.details_flip": "翻轉", + "ui.details_gap": "間隙", + "ui.details_hatch": "為切面畫上剖面線", + "ui.details_jump": "曲率跳變", + "ui.details_key": "圖例", + "ui.details_offset": "偏移", + "ui.details_plane": "平面", + "ui.details_pull": "拔模方向", + "ui.details_radius": "半徑", + "ui.details_readout": "讀值", + "ui.details_samples": "取樣數", + "ui.details_seam": "{body} · 邊 {edge}", + "ui.details_settings": "設定", + "ui.details_sharp_gap": "尖銳或間隙", + "ui.details_show_all": "顯示全部 {count} 項", + "ui.details_smooth": "平滑", + "ui.details_stripes": "條紋", + "ui.details_target": "縮放至幾何", + "ui.details_worst_seams": "最差的接縫", + "ui.details_zebra_caption": "反射的條紋顯示面接合得有多平滑。", "ui.diameter": "直徑", "ui.dimension": "尺寸", "ui.direct_interaction": "直接互動", @@ -4131,6 +4487,7 @@ "ui.equation_y_of_x": "y(x)", "ui.equivalent_radius": "等效半徑", "ui.expand": "展開", + "ui.explode_needs_two_components": "「爆炸圖」會將元件拉開,因此設計需要有兩個或更多元件(新建元件、插入設計或由實體建立元件)", "ui.exportDxf": "匯出 DXF…", "ui.export_ellipsis_keymap": "匯出…", "ui.export_keymap_tip": "將按鍵、S 框收藏和工具列匯出為檔案", @@ -4419,12 +4776,17 @@ "ui.plugins_skipped": "有 {count} 個資料夾無法讀取。", "ui.plugins_version": "{version}", "ui.point": "點", + "ui.point_cloud_add_filter": "新增篩選…", "ui.point_cloud_auto_range": "自動範圍", "ui.point_cloud_color_by": "顏色依據", "ui.point_cloud_file_colors": "檔案顏色", + "ui.point_cloud_filter_by": "篩選依據", "ui.point_cloud_height_high": "最高", "ui.point_cloud_height_low": "最低", + "ui.point_cloud_legend_filter": "在上方圖例中顯示與隱藏", "ui.point_cloud_no_file_colors": "檔案沒有顏色", + "ui.point_cloud_pause": "暫停", + "ui.point_cloud_play": "播放", "ui.point_cloud_single_color": "單一顏色", "ui.point_cloud_size": "點大小", "ui.point_cloud_units": "檔案單位", @@ -4453,6 +4815,7 @@ "ui.properties.inertia_y": "Iyx, Iyy, Iyz", "ui.properties.inertia_z": "Izx, Izy, Izz", "ui.properties.measuring": "正在測量…", + "ui.properties.measuring_bodies": "正在測量… 第 {done} 個,共 {total} 個實體", "ui.properties.measuring_failed": "測量在完成前已停止。", "ui.properties.principal_axis": "主軸 {number}", "ui.properties.principal_moments": "主慣性矩", @@ -4520,6 +4883,7 @@ "ui.selectOther": "選擇其他…", "ui.selectTangentChain": "選擇相切鏈", "ui.select_all": "全選", + "ui.select_components_by_face": "選擇元件(點選面即選取其元件)", "ui.selection": "選擇", "ui.selection_modifiers": "選擇修飾鍵", "ui.selection_shift_add_only": "Shift+點擊僅新增、不取消選擇", @@ -4561,6 +4925,11 @@ "ui.sketch_intersect_selection_filter": "選擇篩選", "ui.sketch_modifiers": "草圖修飾鍵", "ui.sketch_project_geometry": "幾何", + "ui.sketch_report.all_constrained": "全部都已完全拘束。", + "ui.sketch_report.dof": "自由度", + "ui.sketch_report.loose": "尚未完全拘束", + "ui.sketch_report.status": "狀態", + "ui.sketch_report.title": "草圖報告", "ui.sketch_scale": "草圖比例", "ui.sketches": "草圖", "ui.slice": "切片", @@ -4670,7 +5039,14 @@ "ui.viewport_status_key": "顯示 {name} 的圖例", "ui.viewport_status_minimum_radius": "最小半徑", "ui.viewport_status_pairs": "{count} 對", + "ui.viewport_status_section_hatch": "{setting} · 剖面線", + "ui.viewport_status_section_plane": "目前平面", + "ui.viewport_status_section_tilted": "傾斜 · {offset}", "ui.viewport_status_stripes": "{count} 道條紋", + "ui.viewport_status_summary_clear": "全部關閉", + "ui.viewport_status_summary_heading": "此檢視中", + "ui.viewport_status_summary_label": "{name} 及另外 {count} 項", + "ui.viewport_status_summary_more": "+{count}", "ui.viewport_theme_follow": "跟隨主題", "ui.visual_shaded_hidden_edges": "含隱藏邊的著色", "ui.visual_shaded_with_edges": "含邊線的著色", diff --git a/assets/locales/zh.json b/assets/locales/zh.json index 860836c2b2..d7d30878f8 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -822,6 +822,7 @@ "cmd.appearance_pick_up": "拾取外观", "cmd.appearance_replace": "替换外观", "cmd.assembly_bom": "物料清单", + "cmd.assembly_mass_properties": "质量属性", "cmd.body.print_face": "打印底面", "cmd.body_rename": "重命名实体", "cmd.browser_collapse_all": "全部折叠", @@ -1247,6 +1248,7 @@ "cmd.view.render_browser": "渲染浏览器", "cmd.view.render_dock": "移动渲染停靠面板", "cmd.view.render_focus": "渲染专注视图", + "cmd.view.status_details": "视口状态详情", "cmd.view.status_items": "视口状态项", "cmd.view_ambient_occlusion": "环境光遮蔽", "cmd.view_angle_readout": "视图角度", @@ -1285,8 +1287,14 @@ "color.hex_hint": "#rrggbb", "color.hue": "色相", "color.linear": "以 0–1 线性空间显示颜色值", + "color.recent": "最近使用的颜色", + "color.recent_help": "在任意颜色字段中选择的颜色会显示在这里", + "color.saturation": "饱和度(百分比)", "color.saturation_value": "饱和度和明度", "color.selected": "所选颜色", + "color.step_down": "减少一步", + "color.step_up": "增加一步", + "color.value": "明度(百分比)", "config.column_filter_empty": "没有匹配的列", "contact.components_count": "{count} 个组件", "contact.enabled_label": "已启用", @@ -1333,6 +1341,7 @@ "dialog.dimension_name": "尺寸:{name}", "dialog.edit_title": "编辑 {title}", "dialog.no_shown_body": "没有可更改的显示实体:请显示一个实体,或选择新建实体", + "dialog.pattern_sketch_not_object": "请选择实体或特征:草图两者都不是。请用草图自身的阵列或镜像重复其曲线,或先将其拉伸。", "dialog.select": "↖ 选择", "dialog.selected_count": "↖ 已选择 {count} 个", "dialog.selection_guidance": "选择输入项进行修改。点击已选对象可取消选择;按住 Ctrl/Cmd 点击也可切换选择。检查预览后,再按“确定”。", @@ -1390,21 +1399,168 @@ "from the rule": "来自规则", "help.apache": "Apache 许可证 2.0", "help.appearance.assign": "为所选的实体、面或组件设置颜色或材质外观,不改变其重量。用于区分零件或预览表面效果。", + "help.assembly.bom": "按零件号列出设计或组件的零件及其数量,并可将清单保存为 CSV 或 JSON。用作采购和装配的物料清单。", "help.attributions": "署名信息 (ATTRIBUTION.md)", "help.build": "版本 {version} · 提交 {commit} · 构建日期 {date}", + "help.canvas.decal": "像贴纸一样将图像放到模型的平面上。用于在零件上显示标签、标志或印刷图案。", + "help.canvas.edit": "修改画布:位置、大小、角度、不透明度、镜像、可见性或名称。用于将参考图像与在其上描绘的草图对齐。", + "help.canvas.insert": "将 PNG 或 JPEG 图像作为半透明参考放在平面或面上。用于按已知尺寸缩放图像后,在草图中描绘照片或图纸。", + "help.component.break_link": "断开链接组件与其设计文件的关联,保留其实体不变。用于零件不再需要跟随原文件修改时。", + "help.component.capture_position": "将手动移动过的零件的位置确定为其新位置。用于拖动未被接头约束的零件到位之后。", + "help.component.create": "向设计添加一个新的空组件,除非另选父级,否则放在当前激活的组件中。用于为装配中的新零件提供存放实体和草图的位置。", "help.component.finish_edit": "保存正在原位编辑的零件,返回其装配体,并用该零件更新装配体。对链接零件的更改完成后使用。", + "help.component.from_bodies": "将每个选中的实体变成一个独立的组件,并以实体名称命名。用于把由零散实体构成的设计变成可用接头连接和移动零件的装配。", + "help.component.insert_design": "将另一个已保存的设计作为链接到其文件的组件插入,其实体保持在原设计中的位置。用于在装配中复用零件,并通过“更新链接”获取之后的修改。", + "help.component.properties": "根据各实体的材料,显示组件的体积、质量、质心和转动惯量。用于检查装配体的重量和平衡。", + "help.component.revert_position": "将手动移动过的零件放回原位,放弃尚未捕获的移动。用于撤销装配中的试移动。", + "help.component.update_links": "重新读取链接的设计文件,并替换已更改的实体。用于编辑了此装配所使用的零件之后。", + "help.config.configure": "将参数、特征、材料或外观添加为配置表的列。用于创建同一设计的变体,例如零件的不同尺寸。", + "help.config.preview": "检查配置表的某一行能否重新生成,不改变设计及其历史。用于切换到没有把握的变体之前。", + "help.config.rules": "显示配置表的规则、各行以及当前生效的行。用于了解设计的变体是如何设置的。", + "help.config.table": "显示配置表:每个变体一行,每个参数或特征一列,并指出设计是否仍与当前生效的行一致。用于检查设计的各个变体。", "help.connect_note": "请重启各个助手以加载 SolidCraft 服务器。每个被修改的文件都已备份为 .bak。", "help.connect_title": "连接 AI 助手", + "help.construct.axis.edge": "沿直边或草图线创建构造轴。用作旋转、圆周阵列或接头的轴。", + "help.construct.axis.normal_to_face": "创建一条过某点、垂直于面的构造轴。用作从表面笔直伸出的轴。", + "help.construct.axis.through_cylinder": "通过圆柱、圆锥或圆环面的中心,或圆形边的中心创建构造轴。用于绕圆形零件旋转或阵列特征。", + "help.construct.axis.two_planes": "在两个平面或平整面相交处创建构造轴。用于所需的轴正是两个面之间的交线时。", + "help.construct.axis.two_points": "通过两个点、顶点或草图点创建构造轴。用于连接两个已知位置的轴。", + "help.construct.plane.along_path": "在边或曲线上的指定位置创建垂直于它的构造平面。用于为扫掠绘制轮廓,或绘制路径的截面。", + "help.construct.plane.at_angle": "创建一个由平面或面绕轴或边旋转一定角度而成的构造平面。用于倾斜的草图,例如斜支架。", + "help.construct.plane.midplane": "在两个平面或平整面的正中间创建构造平面;若二者成角相交,则取其角平分面。用作镜像的对称面。", + "help.construct.plane.offset": "创建与平面或平整面平行的构造平面,按距离或过某点确定。用于在面上方指定高度处绘制草图。", + "help.construct.plane.perpendicular": "创建一个包含某直线或边、并垂直于某平面或面的构造平面。用于沿边在模型上垂直地绘制草图。", + "help.construct.plane.tangent": "创建在某点与圆柱、圆锥、球或圆环相切的构造平面。用于在圆形零件的侧面绘制草图。", + "help.construct.plane.three_points": "通过三个点创建构造平面。用于由模型上的位置(如三个角)确定的平面。", + "help.construct.plane.two_edges": "通过位于同一平面内的两条直边或直线创建构造平面。用于穿过零件的平面,例如通过两条相对的边。", + "help.construct.point.along_path": "在边或曲线上的指定位置创建构造点。用作路径中途的参考。", + "help.construct.point.center": "在圆形边、草图圆或圆弧、球或圆环的中心创建构造点。用作孔、轴和接头的参考。", + "help.construct.point.edge_plane": "在边或直线穿过平面或平整面处创建构造点。用于标出路径与平面相交的位置。", + "help.construct.point.three_planes": "在三个平面或平整面相交处创建构造点。即使模型上没有顶点,也可用作角点参考。", + "help.construct.point.two_edges": "在两条边或直线相交处(或相距最近处)创建构造点。用作虚拟角点处的参考。", + "help.construct.point.vertex": "在顶点或草图点上创建构造点。用作参考,即使周围的草图或实体被隐藏也会保留。", + "help.contact.check": "列出接触集中在当前位置相互穿透的零件对。用于在驱动接头或运行运动研究之前查找碰撞。", + "help.contact.create": "将两个或更多零件组成一个接触集,在接头运动时其实体不会相互穿透。用于接触时应停止的零件,例如抵住限位块的杠杆。", + "help.contact.disable_all": "关闭接触,驱动接头和运动研究在零件接触处不再停止。用于重新自由移动零件,或让大型装配保持流畅。", + "help.contact.edit": "修改接触集:其包含的零件、名称或是否抑制;也可以删除该接触集。用于调整哪些零件不得相互穿透。", + "help.contact.enable_all": "将装配中的所有零件放入同一个接触集,使接头运动时任何两个零件都不会相互穿透。用于快速检查整个机构。", + "help.contact.enable_sets": "为已定义的接触集开启接触,驱动接头和运动研究会在其零件即将相互穿透处停止。用于在关闭接触后恢复这些接触集。", + "help.contact.list": "列出设计中的接触集、每个接触集包含的零件以及接触是否开启。用于在驱动装配之前检查接触设置。", + "help.document.inspect": "显示设计的概览:组件、实体、草图、特征和参数。用于了解不是自己创建的设计。", + "help.document.units": "更改设计显示长度所用的单位,如 mm 或英寸。几何大小不变,只有显示和纯数字随之改变。", + "help.edit.checkpoint": "记住设计、当前草图和撤销历史的现状。用于尝试有风险的操作之前,以便日后可以回退。", + "help.edit.restore_checkpoint": "回到某个检查点,撤销历史一并还原。用于一步丢弃检查点之后的所有操作。", + "help.explode.create": "创建爆炸图,将零件从装配中心向外推开,或按设定的位移移动。用于在说明书或演示中展示装配的组装方式。", "help.face.delete": "删除所选的面,并由相邻的面封闭留下的缺口,也可以让缺口保持开放、成为曲面。用于去掉碍事的圆角、孔或凸台。", + "help.file.insert_mesh": "将 3MF、STL、OBJ、PLY 或 glTF 文件作为新组件中的网格实体添加到设计中。用于把下载或扫描得到的模型作为参考放在设计旁边。", + "help.file.insert_step": "将 STEP、IGES、FreeCAD 或 Rhino 文件作为新组件中的实体添加到设计中;装配中的零件会成为嵌套组件。用于导入供应商或其他 CAD 程序的模型。", + "help.file.save_mesh": "将实体保存为 STL、3MF 或 OBJ 网格,曲面的平滑程度可选。用于为 3D 打印或读取网格的程序准备零件。", + "help.file.send_to_slicer": "将选中的实体保存为 3MF 文件,并在所选的切片软件中打开。用于一步从模型进入打印。", "help.font_details": "界面使用 egui 的内置字体(Ubuntu Light 和 Hack,采用 Ubuntu Font Licence 和 MIT/Bitstream Vera 许可证;Noto Emoji 和表情图标字体,采用 OFL 和 MIT 许可证)。草图文本使用 Liberation Sans(SIL OFL 1.1)。The Crafting Apps 的可选字体包 craft-fonts(BIZ UDPGothic、Shippori Mincho、BIZ UDMincho、Noto Sans CJK SC、Noto Sans Arabic;SIL OFL 1.1)未随 SolidCraft 构建版本捆绑提供。", "help.fonts": "字体", + "help.frame.member": "沿框架草图的线放置型材(如管材或铝型材),每条线一根构件。用于构建框架、货架和焊接结构。", + "help.inspect.accessibility": "给刀具从某一方向能够到达和无法到达的面着色。用于检查零件从该方向加工或成型是否可行。", + "help.inspect.center_of_mass": "在均匀密度下显示各实体单独以及整体的质心。用于检查平衡和零件的倾倒位置。", + "help.inspect.colored_components": "给每个组件一种临时颜色,不改变外观。用于一眼分辨装配体的各个零件。", + "help.inspect.compare": "比较两个实体,显示其中一个有而另一个没有材料的位置,并给出体积和偏差。用于对照原件检查修改过或导入的零件。", + "help.inspect.continuity": "检查各面沿边相接的平滑程度:间隙、折角或曲率跳变,并给接缝着色。用于找出曲面衔接不干净的地方。", + "help.inspect.curvature_comb": "沿曲线、边或面绘制梳状线,梳齿显示弯曲的强弱。用于发现平坦处、折角和曲率不均。", + "help.inspect.curvature_map": "按弯曲程度给面着色,半径小的地方为红色。用于找出急弯处并判断曲面是否光滑。", + "help.inspect.draft": "按各面相对拔模方向的拔模角着色,标出角度不足的面。用于注塑或铸造前检查零件。", + "help.inspect.environment_map": "以反光的镜面效果显示模型。用于根据反射的走向判断曲面的流畅程度。", + "help.inspect.interference": "找出相互重叠的实体,并显示每处重叠及其体积。用于检查装配体中是否有零件发生碰撞。", + "help.inspect.isocurve": "绘制面的等参线,即曲面所依据的网格。用于查看曲面的走向以及线条密集之处。", + "help.inspect.measure": "测量所选对象之间的长度、距离、角度、面积和半径,或实体的体积和质量。用于检查尺寸和间隙。", + "help.inspect.minimum_radius": "找出曲线、边或面的最小曲率半径及其位置。用于检查刀具或喷嘴能否沿形状走刀。", + "help.inspect.section": "沿平面剖开视图并为截面填充剖面线,不改变模型。用于查看零件和装配体的内部。", + "help.inspect.zebra": "在模型的面上显示反射的黑白条纹。用于判断光顺程度:条纹在边处断开或折弯,说明过渡不好。", + "help.joint.as_built": "在两个零件当前所在位置将其连接,不移动任何一个,并设定它们仍可如何运动。用于已放置在最终位置的零件。", + "help.joint.cam": "将旋转的凸轮与从动件关联,使从动件随凸轮轮廓滑动或摆动。用于凸轮、偏心机构和气门传动。", + "help.joint.create": "在所选的接头原点处连接两个零件,并把第二个零件移到位;接头类型决定它们仍可如何运动。用于构建零件像真实机构一样运动的装配。", + "help.joint.drive": "在限制范围内设定接头的角度或距离,并带动零件移动;关联的接头和闭环随之运动。用于检查机构是否按预期运动。", + "help.joint.gear": "以齿轮传动比关联两个旋转接头,驱动其中一个时另一个随之转动。用于直齿轮和内齿轮,传动比由节圆半径或齿数确定。", + "help.joint.limits": "以最小值和最大值设定接头可转动或滑动的范围,或清除这些限制。用于使铰链、滑块或杠杆保持在其实际行程内。", + "help.joint.motion_link": "以比例和偏移关联两个接头,驱动其中一个时另一个随之运动。用于“齿轮”“齿轮齿条”或“螺旋”无法表达的接头运动关系。", + "help.joint.origin": "在零件上放置一个命名的接头原点:位于面或圆形端部的中心、边的中点、角点或某一点。用于预先确定接头的连接位置;在面上选取的原点会在零件尺寸改变时跟随该面。", + "help.joint.rack_pinion": "将旋转的小齿轮与滑动的齿条关联,齿条移动的距离等于小齿轮节圆滚过的弧长。用于齿轮齿条传动、转向机构和直线平台。", + "help.joint.reach": "通过在限制范围内改变链上的接头,将零件上的某一点移向目标。用于按末端应到达的位置摆放机构,例如机械臂的末端。", + "help.joint.rigid_group": "将选中的零件按当前位置锁定在一起,使其作为一个整体运动。用于彼此之间没有相对运动的成组零件。", + "help.joint.screw": "以螺距关联转动与滑动,每转一圈前进相应距离。用于丝杠、螺栓和螺纹轴。", + "help.library.apply": "将本地库文件中的材料或外观应用到实体、面或组件。用于重复使用在其他设计中保存的材料。", + "help.library.export": "将设计的材料和外观保存到库文件。用于共享,或在其他设计中重复使用。", + "help.library.inspect": "检查库文件并列出其中的材料和外观,不改变设计。用于在应用之前查看库里有什么。", "help.licences": "许可证", "help.material.assign": "设置所选实体的材料,例如钢、铝或 ABS。用于让质量和重量与实际零件一致。", + "help.material.properties.validate": "检查材料数据卡的数值是否一致、有无缺失。用于在依赖这些数据计算质量或刚度之前。", + "help.mesh.convert": "将网格实体(如 STL)重建为带平面和圆柱面的可编辑实体。用于像修改建模实体那样修改导入的机械零件。", "help.mit": "MIT 许可证", + "help.motion.study": "在时间轴的各个步骤设定接头值,并以动画方式回放。用于观察机构完成一系列动作,并检查过程中的间隙。", + "help.motion.trace": "求出运动研究播放或驱动接头时零件上某一点经过的轨迹,确认后保留为三维草图曲线。用于查看机构的可达范围,或在该轨迹基础上建模。", "help.notice": "NOTICE", + "help.occurrence.ground": "将零件固定在原位,或重新解除固定,接头会围绕它移动其他零件。用于装配中其余零件所依托的框架或底座。", "help.occurrence.move": "仅沿关节允许的自由度移动装配零部件。选中零部件的面后,拖动可用的箭头或圆弧。", "help.parameters.change": "打开驱动设计尺寸的命名数值和表达式表。用于在一处修改尺寸,让所有引用它的特征随之更新。", + "help.parameters.export": "将设计的参数保存为 CSV 或 JSON。用于在电子表格中编辑,或转移到其他设计。", + "help.parameters.graph": "显示哪些参数和特征使用了哪些其他参数和特征,以及其中的循环或错误。用于了解改动某个参数会影响什么。", + "help.parameters.import": "将 CSV 或 JSON 中的参数读入设计,要么全部读入,要么一个也不读。用于从电子表格或其他设计带入数值。", + "help.parameters.move": "将某个用户参数移到列表中另一个参数之前或之后。用于把相关参数放在一起。", + "help.parts.fastener": "按所选尺寸添加标准螺钉、螺栓、螺母、垫圈、销或轴承。放在孔上时会通过接头安装在孔中,无需自己建模紧固件。", + "help.parts.insert": "按所选类别、尺寸和长度插入标准零件。放在孔的边缘或孔壁上时,会通过刚性接头安装在孔上。", + "help.parts.library": "打开标准零件:标准尺寸的螺钉、螺栓、螺母、垫圈、紧定螺钉、定位销和轴承。用于直接添加标准件,而无需自己建模。", + "help.parts.resize": "修改已插入标准零件的尺寸或长度并重建;其接头保持安装位置不变。用于螺钉过长或孔需要其他尺寸时。", + "help.plastic.assign_rule": "将塑料规则指定给实体,使其塑料特征默认采用该规则的拔模和间隙。用于一次设置好注塑件的所有特征。", + "help.plastic.boss": "在面上添加螺钉凸台:带可选孔、拔模、根部圆角和加强筋的立柱。用于注塑或打印外壳中的螺钉位。", + "help.plastic.lip": "沿抽壳实体的边缘添加凸缘或与之配合的凹槽。用于让外壳的两半相互定位并紧密闭合。", + "help.plastic.manage_rules": "创建和编辑塑料规则:材料、壁厚、半径、间隙和拔模。用于保持注塑件各特征一致。", + "help.plastic.rest": "在面上添加垫块:圆形或矩形的凸垫,可实心或空心,带拔模。用作注塑外壳中的支撑或定位柱。", + "help.plastic.snap_fit": "在面上添加卡扣钩,可设置其长度、厚度和卡爪。用于不用螺钉将零件卡在一起。", "help.pointcloud.insert": "将 PLY、XYZ 或 PTS 扫描作为点云插入到模型旁。根据测量数据建模时,可将其用作参考。", + "help.sheet.convert": "将厚度均匀的板或抽壳实体转换为钣金实体,在所选的边处折弯。用于让现有零件可以展开。", + "help.sheet.export_bend_table": "将钣金实体的折弯表保存为 CSV 或 JSON:角度、半径以及展开图中的折弯位置。用于折弯机或车间。", + "help.sheet.export_dxf": "将展开图保存为 DXF,包含轮廓、折弯线和可选的折弯注释。用于激光、等离子或水刀切割。", + "help.sheet.flange": "添加法兰:由草图生成的基板、沿开放草图轮廓的壁,或在钣金边上折弯的壁。用于构建钣金零件。", + "help.sheet.flat_pattern": "创建钣金实体的展开图,含尺寸、切口和折弯线。用于在切割前检查坯料。", + "help.sheet.fold": "沿草图线按指定角度和半径折弯钣金。用于在平板上切好特征之后将其折叠。", + "help.sheet.hem": "将钣金边缘折回贴合自身。用于获得安全、坚固且没有锐利毛刺的边缘。", + "help.sheet.manage_rules": "创建和编辑钣金规则:厚度、折弯半径、K 因子、释放槽和间隙。用于匹配车间的材料和工具。", + "help.sheet.path_flange": "沿钣金边按开放的草图线链添加一串法兰。用于一步生成 U 形或 Z 形等型材。", + "help.sheet.perforate": "在整块展开的钣金上添加孔阵,排列为交错或方格。用于通风口、格栅和减重面板。", + "help.sheet.refold": "将展开的钣金实体重新折回。用于在展开的钣金上添加特征之后。", + "help.sheet.unfold": "展开钣金实体的所有折弯。用于添加需要跨越折弯的孔或切口,之后再重新折弯。", + "help.sketch.arc.center_point": "在圆心周围绘制一段圆弧,从起点到终点。适用于圆心已知的情况,例如孔周围或转轴周围的半径。", + "help.sketch.arc.tangent": "从直线或圆弧的端点出发,绘制与其相切的圆弧。用于以平滑的弯曲而不是尖角延续链条。", + "help.sketch.arc.three_point": "绘制从起点经过第三个点到终点的圆弧。适用于圆弧必须经过给定的点、而不是围绕已知圆心的情况。", + "help.sketch.arc3d": "开启3D 草图后,通过三点在空间中绘制精确的圆弧。用于离开草图平面的路径和导轨中的弯曲。", + "help.sketch.auto_constrain": "添加草图已近乎满足的约束,例如水平、平行或重合,然后标注尺寸直到完全定义。用于一步锁定画得比较随意的草图。", + "help.sketch.auto_constrain_from_datum": "与自动约束类似,但位置是从你选取的基准点而不是原点开始测量。适用于以某个参考点(如角点或孔)布置零件的情况。", + "help.sketch.blend_curve": "用与两条曲线都相切的平滑样条曲线连接两者的端点,也可选择曲率连续。用于无折痕地桥接曲线之间的间隙。", + "help.sketch.break": "在其他曲线与其相交处,或在你放置的点处,把曲线拆分成若干段。适用于曲线的某一段需要单独约束或单独删除的情况。", + "help.sketch.cancel": "离开草图,并放弃自创建或开始编辑以来的所有更改。撤销可恢复这些更改。", + "help.sketch.centerline": "在普通直线与中心线之间切换,中心线是轮廓中不包含的轴线。把中心线用作旋转、镜像或对称约束的轴。", + "help.sketch.chamfer.distance_angle": "按沿第一条直线的距离和一个角度,对两条直线之间的拐角倒角。适用于图纸以长度加角度给出倒角的情况,例如 2 mm × 30°。", + "help.sketch.chamfer.equal_distance": "沿两条直线向后退相同距离,对它们之间的拐角倒角。用于轮廓上常见的 45° 倒角。", + "help.sketch.chamfer.two_distance": "按沿每条直线各自的距离,对两条直线之间的拐角倒角。用于以两个长度给出的非 45° 倒角。", + "help.sketch.circle.center": "根据圆心和直径绘制圆。用于圆心已知的孔、凸台和其他圆形特征。", + "help.sketch.circle.three_point": "绘制经过三个点的圆。适用于圆必须经过给定点的情况,例如已有几何体上的点。", + "help.sketch.circle.three_tangent": "绘制与三条直线、圆或圆弧都相切的圆。用于在三条曲线之间放入一个圆,例如三角形的内切圆。", + "help.sketch.circle.two_point": "根据直径的两个端点绘制圆。适用于已知圆周上的点而非圆心的情况。", + "help.sketch.circle.two_tangent": "绘制指定大小、与两条直线、圆或圆弧相切的圆。用于在两条曲线之间做固定半径的圆角过渡。", + "help.sketch.conic": "在两个端点之间绘制圆锥曲线,形状由顶点和 rho 值决定。用于需要调节饱满度的平滑过渡,从平缓到接近尖角都可以。", + "help.sketch.constrainer": "应用所选的一两条曲线已近乎满足的约束,例如水平、平行、相切或相等。用于逐个关系地整理手绘草图。", + "help.sketch.constraint.coincident": "使一个点位于另一个点、曲线或草图轴上。用于闭合轮廓中的间隙,或把点放到直线上。", + "help.sketch.constraint.collinear": "使两条直线位于同一条直线上。用于对齐被打断的边,例如缺口的两侧。", + "help.sketch.constraint.concentric": "使两个圆或圆弧具有相同的圆心。用于圆环、沉孔和带台阶的轴。", + "help.sketch.constraint.curvature": "使在端点相接的两条曲线以相同的曲率延续 (G2)。用于相切连接不够平滑的地方,例如可见曲面。", + "help.sketch.constraint.equal": "使直线长度相等,或使圆和圆弧半径相等。用于让一个尺寸同时驱动多个相同的特征。", + "help.sketch.constraint.fix": "把点或曲线固定在当前位置,或再次解除固定。用于拖动或解算草图时不得移动的几何体。", + "help.sketch.constraint.horizontal_vertical": "使一条直线或两点之间的连线水平或竖直,取更接近的方向。用于让轮廓与草图坐标轴对齐。", + "help.sketch.constraint.midpoint": "把点放在直线的中间,或使两条直线的中点重合。用于使几何体相对于某条边居中。", + "help.sketch.constraint.parallel": "使两条直线平行。用于其他尺寸变化时必须保持平行的边。", + "help.sketch.constraint.perpendicular": "使两条直线以直角相交。用于不在草图坐标轴上的直线之间的直角拐角。", + "help.sketch.constraint.polygon": "把封闭的直线链变成正多边形:各边相等,所有顶点位于同一个圆上。用于让手绘的多边形变成正多边形并保持如此。", + "help.sketch.constraint.symmetry": "使两个点或曲线关于一条直线或一个点互为镜像。用于修改一侧时保持轮廓对称。", + "help.sketch.constraint.tangent": "使两条曲线相切,例如直线与圆弧。用于无折痕的平滑连接。", + "help.sketch.construction": "在普通几何与构造几何之间切换,构造几何不参与轮廓。把构造曲线用作布局、参照和尺寸标注的辅助。", "help.sketch.construction_line": "创建经过两点的无限构造直线,用于辅助绘制草图而不分割轮廓。", "help.sketch.construction_line.angle": "创建与草图X轴成指定角度的无限构造直线。选择一点后输入角度或选择方向。", "help.sketch.construction_line.bisector": "选择两条相交的草图直线,再选择所需的角平分线。结果随原直线的编辑而变化。", @@ -1415,12 +1571,71 @@ "help.sketch.construction_line.tangent_external": "依次选择圆或圆弧、外部点和所需切线。预览仅显示接点位于已绘圆弧上的解。", "help.sketch.construction_line.tangent_two": "选择两个圆或圆弧,再从预览中选择公切线。接点必须位于已绘曲线上。", "help.sketch.construction_line.vertical": "创建经过指定点的无限竖直构造直线。后续编辑时保持竖直约束。", + "help.sketch.copy": "复制所选的草图曲线和点,以及它们之间的约束和尺寸。可粘贴到本草图或另一个草图中。", + "help.sketch.create": "在平面或模型的平面上开始新的二维草图。每个拉伸、旋转和扫掠都从草图开始。", + "help.sketch.cut": "复制所选的草图几何并将其从草图中删除,只需一步撤销。可粘贴到本草图的其他位置或另一个草图中。", + "help.sketch.dimension": "添加驱动尺寸:根据所选对象为长度、距离、角度、半径或直径。输入数值或参数名,草图随之变化。", + "help.sketch.edit": "打开已有的草图进行编辑。完成草图后,基于它创建的特征会随之更新。", + "help.sketch.ellipse": "根据圆心、长轴端点和短轴一侧的一个点绘制椭圆。用于椭圆形开口、凸轮和其他细长的圆形形状。", "help.sketch.equation_curve": "在一个范围内按公式绘制曲线:x(t) 和 y(t),或 y = f(x)。公式可以使用参数,曲线随参数变化。适用于正弦波、螺旋线或齿轮的渐开线。", + "help.sketch.extend": "把直线或圆弧延长到途中最近的曲线,或把两条曲线延伸到相交。用于在修剪或编辑后闭合轮廓中的间隙。", + "help.sketch.fillet": "用指定半径的圆弧把两条直线或圆弧相交的拐角倒圆。同时倒圆的多个拐角共用同一个半径。", + "help.sketch.finish": "离开草图并返回模型。其封闭轮廓随即可用于拉伸、旋转和其他特征。", + "help.sketch.finish_auto_constrain": "对草图运行自动约束,然后完成草图。用于一步得到完全定义的草图。", + "help.sketch.fit_curves_to_section": "用草图平面剖切网格或实体,并用直线和圆弧拟合截面。用于把扫描或导入的零件重建为可编辑的草图。", + "help.sketch.include_3d": "把模型的边、顶点和其他草图的曲线作为关联参照引入本草图,并把不在其平面上的几何压平到平面上。用于基于不在草图平面内的几何继续作图。", + "help.sketch.infinite": "在普通直线与贯穿整个视图的无限构造直线之间切换。用作永远不会过短的布局参照。", + "help.sketch.insert_dxf": "将 DXF 图纸中的曲线读入草图,并按设定进行缩放、旋转和放置。用于在其他程序制作的二维图纸(例如激光切割轮廓)基础上建模。", + "help.sketch.insert_svg": "将 SVG 图形中的路径读入草图,并按设定进行缩放、旋转和放置。用于把标志或矢量轮廓变成可拉伸的曲线。", "help.sketch.inspect": "打开当前草图的报告:显示剩余的自由度以及是否已完全约束。在完成草图之前,用它找出仍然可以移动的部分。", + "help.sketch.intersect": "添加实体、面、边或其他草图与草图平面相交处的曲线或点。它们保持关联,并随模型变化。", + "help.sketch.intersection_curve": "在两个实体或面相交处创建与它们关联的三维曲线。用作跟随模型的路径或导轨。", + "help.sketch.isoparametric_curve": "通过你选取的点,沿面的 U 或 V 方向创建与该面关联的三维曲线。用作跟随曲面的路径或分割线。", + "help.sketch.line": "逐点绘制相连的直线,可以是开放链或封闭轮廓。这是绘制大多数轮廓的基本工具。", + "help.sketch.line.midpoint": "从中点向一端绘制直线,另一端随之对称生成。用于以某点为中心的直线,例如对称的边。", + "help.sketch.line3d": "开启3D 草图后,在空间中的点之间绘制相连的直线。用于离开草图平面的路径和框架。", + "help.sketch.mirror": "把草图曲线和点关于直线或轴镜像,并添加对称约束。用于只画对称轮廓的一半,再将其补全。", + "help.sketch.move": "移动或旋转草图曲线和点,或它们的副本。用于在保持形状和约束的同时调整几何的位置。", + "help.sketch.offset": "按设定的距离在一侧或两侧创建曲线链的副本。用于壁、轮廓和间隙;距离仍是可编辑的尺寸。", + "help.sketch.paste": "把复制或剪切的草图几何粘贴到当前草图。可放置在某一点,或按偏移量移动。", + "help.sketch.pattern.circular": "绕中心点复制草图几何,可绕整圆或按设定角度。用于螺栓圆、辐条和齿。", + "help.sketch.pattern.rectangular": "按设定间距以行和列复制草图几何。用于孔、槽或通风口的阵列。", + "help.sketch.point": "放置一个草图点。用作尺寸、约束、孔心或后续特征的参照。", + "help.sketch.point3d": "开启3D 草图后,在空间中放置一个点。用作三维曲线和路径的参照。", + "help.sketch.polygon.circumscribed": "在圆外绘制正多边形,大小由中心到边中点的距离决定。适用于已知对边距的情况,例如六角螺母。", + "help.sketch.polygon.edge": "根据一条边的两个端点绘制正多边形。适用于已知边的长度和位置的情况。", + "help.sketch.polygon.inscribed": "在圆内绘制正多边形,大小由中心到顶点的距离决定。适用于已知对角距的情况。", + "help.sketch.project": "把模型的边、面、顶点和其他草图的曲线投影到本草图的平面上并引入。它们保持关联,并随模型变化。", + "help.sketch.project_to_surface": "把其他草图的曲线沿本草图的法线方向投影到面上,生成关联的三维曲线。用于把标志、分割线或路径放到曲面上。", + "help.sketch.rectangle.center": "根据中心和一个角点绘制矩形。用于以某点(如原点)为中心的轮廓。", + "help.sketch.rectangle.rounded": "绘制四个角都倒成同一半径圆角的矩形。用于带圆角的板、盖和开口。", + "help.sketch.rectangle.three_point": "根据一条边和对侧的一个点绘制矩形。用于与草图坐标轴成角度的矩形。", + "help.sketch.rectangle.two_point": "根据两个对角点绘制矩形。用于板、块体和凹槽的轮廓。", + "help.sketch.scale": "以基点为中心按比例缩放草图曲线和点。用于无需重画就调整草图某一部分的大小。", + "help.sketch.slot.arc_center": "沿由圆心、起点和终点确定的圆弧,绘制指定宽度的弧形槽。用于绕转轴的调节槽。", + "help.sketch.slot.arc_three_point": "沿经过三点的圆弧,绘制指定宽度的弧形槽。适用于槽的路径必须经过给定点的情况。", + "help.sketch.slot.center_point": "从中心到一端圆弧的圆心,绘制指定宽度的直槽。用于以某点为中心的槽。", + "help.sketch.slot.center_to_center": "在两端圆弧的圆心之间,绘制指定宽度的直槽。用于按中心距标注的槽,例如螺钉调节槽。", + "help.sketch.slot.overall": "从一个外端到另一个外端,绘制指定宽度的直槽。适用于槽的总长最重要的情况。", + "help.sketch.spline.control_point": "绘制由控制点决定形状的三次样条曲线,曲线不经过这些点。用于通过移动控制点来塑形的平滑流畅曲线。", + "help.sketch.spline.control_point_5": "绘制由控制点决定形状的 5 次样条曲线。用于曲线需要以更平滑的曲率连接的地方,例如可见的造型曲面。", + "help.sketch.spline.fit_point": "绘制经过你放置的每个点的平滑样条曲线,可开放或封闭。用于通过已知点描出形状。", + "help.sketch.spline3d": "开启3D 草图后,在空间中通过各点绘制平滑曲线。用于离开草图平面的扫掠路径和线缆走线。", + "help.sketch.spun_profile": "在草图平面中创建实体绕轴旋转时扫出的轮廓。用于获得车削零件的剪影,以便旋转或出图。", + "help.sketch.text": "用选定的字体、高度和角度在草图中写文字;文字内容可以跟随参数。拉伸或压印后可做标签和字样。", + "help.sketch.transform": "以中心点为基准,一步完成草图几何的缩放、旋转和移动。用于同时调整所选对象的位置和大小。", + "help.sketch.trim": "删除你点击位置周围、最近两个交点之间的那段曲线。用于把重叠的线整理成封闭轮廓。", "help.solid.align": "移动所选实体,使其上的点、面或平面与别处的点、面或平面对齐。用于把一个零件贴靠到另一个零件上,而无需测量距离。", + "help.solid.boundary_fill": "由相互重叠的工具实体所围成的区域生成实体,只填充所选区域。用于利用多个实体共同围成的空间构建形状。", + "help.solid.bounding_solid": "创建包围选中实体的长方体,可设置余量。用于查看零件的总体尺寸,或作为加工所用的毛坯。", + "help.solid.box": "根据长、宽、高创建长方体,可从角点或以某点为中心放置。用于快速创建块体、占位体或用于切出特征的实体。", "help.solid.chamfer": "用指定的距离在所选边上切出一个平直的斜面。用于去除锐边,或使销钉、螺钉更容易插入。", + "help.solid.coil": "根据直径、圈数、高度或螺距以及圆形或方形截面创建螺旋形实体,例如弹簧。用于弹簧、线圈和螺旋凸筋。", "help.solid.combine": "将多个实体合并、从目标实体中切除,或只保留它们共有的体积,还可以保留工具实体。用于合并零件、用一个形体切割另一个形体,或找出两个实体重叠的位置。", + "help.solid.cone": "根据底圆和高度创建圆锥体;若顶面也有半径,则为圆台。用于锥度、漏斗、沉头孔和尖端。", + "help.solid.cylinder": "根据半径或直径和高度创建圆柱体,立于某点并沿某轴延伸。用于轴、销、凸台和圆形毛坯。", "help.solid.draft": "将所选的面按指定角度倾斜,角度从中性面量起。用于注塑件或铸件,使零件能够脱模。", + "help.solid.emboss": "将文字或标志等草图轮廓从面上凸起或凹入。用于平面或圆柱面上的文字、标签和标记。", "help.solid.extrude": "将草图轮廓按指定距离拉伸为三维形体,可以增加或去除材料。用于把平面轮廓变成方块、凸台或凹槽。", "help.solid.fillet": "用指定半径的平滑圆弧将所选的边倒圆。用于出于强度、手感或外观的考虑而柔化锐边。", "help.solid.hole": "在面上的指定位置钻出指定直径的圆孔。用于螺钉过孔、销孔等简单的圆形开口。", @@ -1429,20 +1644,60 @@ "help.solid.move": "按指定的距离和角度移动或旋转所选实体,或其副本。用于调整实体位置,或在别处再生成一个。", "help.solid.offset_face": "将所选的面按指定距离向内或向外推移,周围的面随之调整。用于在不编辑草图的情况下加厚壁、加深凹槽或加高凸台。", "help.solid.pattern.circular": "将所选对象按指定的数量和总角度绕轴重复排列。用于螺栓圆周分布、辐条、散热片和轮齿。", + "help.solid.pattern.path": "沿草图路径按数量和间距复制特征或实体。用于沿曲线边的孔,或沿导轨的零件。", "help.solid.pattern.rectangular": "将所选对象按指定的数量和间距沿一个或两个方向重复排列。用于成排或成网格的孔、加强筋、销钉或支脚。", + "help.solid.pipe": "沿草图曲线路径创建圆管,可为实心或带壁厚的空心。用于管道、线材、电缆和栏杆。", "help.solid.press_pull": "拖动面来增加或去除材料,或拖动边将其倒圆。用于直接在模型上快速修改。", "help.solid.remove": "从时间线的这一位置起,将所选实体从设计中移除;之前的步骤仍保留它们。用于去掉辅助实体,例如不再需要的切割工具。", "help.solid.replace_face": "将所选的平面移到一个平面或平行的面上,实体随之伸长或缩短。用于让一个面与另一个零件或构造平面齐平。", "help.solid.revolve": "将草图轮廓绕一条轴旋转,生成圆形的实体。用于轴、旋钮、碗等围绕中心线呈圆形的零件。", + "help.solid.rib": "沿开放的草图曲线添加一道薄壁,延伸到实体的下一个面。用于加强壁和角,例如注塑件或打印件。", "help.solid.scale": "按比例放大或缩小所选实体,可在各方向相同,也可按轴分别设置。用于调整导入形体的大小,或预留收缩量。", "help.solid.shell": "将实体掏空,留下指定厚度的壁,还可以打开所选的面。用于盒子、盖子等薄壁零件。", + "help.solid.sphere": "根据半径或直径,以指定点为球心创建球体。用于球、旋钮和圆头端部。", "help.solid.split_body": "用平面、另一实体的面或草图直线,将实体切分为多个独立的实体。用于制作分别打印、加工或上色的零件。", "help.solid.split_face": "沿平面、另一实体的面或草图曲线分割所选的面,而不切开实体。用于给面的一部分单独设置拔模、颜色或偏移。", "help.solid.sweep": "沿路径拖动草图轮廓生成实体。用于管道、导轨、手柄等截面恒定且沿曲线延伸的零件。", + "help.solid.thread": "在圆柱面上添加 ISO 公制螺纹,可作为装饰螺纹或切入实体。用于螺栓、螺纹孔和打印的螺纹零件。", + "help.solid.torus": "根据绕轴的大半径和圆形截面的小半径创建圆环。用于 O 形圈、把手和环形凸筋。", + "help.solid.web": "沿每条开放的草图曲线各添加一道薄壁,均延伸到实体的下一个面。用于外壳和托盘内部的网格支撑壁。", + "help.surface.blend": "创建桥接两个实体边的曲面,与两侧相切连续或曲率连续。用于在间隙上将两个曲面平滑连接。", + "help.surface.boundary": "创建填充由二至四条草图曲线构成的环或曲线网络的曲面。用于由边界定义的自由曲面面板。", + "help.surface.extend": "将曲面延伸出其边缘,按距离或直至某个平面或面。用于在修剪或缝合之前让曲面相互重叠。", + "help.surface.extrude": "将草图曲线或轮廓按一段距离拉伸为曲面,只有壁,没有端盖。用于薄壁,以及切割或分割工具。", + "help.surface.fair": "在保持边不动的前提下,使自由曲面的内部变得光顺。用于抚平曲面上的凸起和波浪状反射。", + "help.surface.loft": "创建按顺序穿过两个或更多截面的曲面,无端盖。用于外壳以及不同形状之间的过渡。", + "help.surface.match": "重建曲面的边,使其与另一实体的边衔接,并匹配相切或曲率。用于平滑地闭合两个曲面之间的接缝。", + "help.surface.offset": "将面沿法向按一定距离复制为新的曲面实体。用于蒙皮、间隙,或壁的另一侧。", + "help.surface.patch": "用平面或平滑弯曲的面填充草图区域或边的闭合环。用于封闭曲面实体上的孔。", + "help.surface.reverse_normal": "翻转曲面实体的面,使其外侧朝向反方向。用于面朝内时,在加厚或缝合之前。", + "help.surface.revolve": "将草图曲线或轮廓绕轴旋转为曲面,无端盖。用于灯罩、碗、喷嘴等圆形壳体。", + "help.surface.ruled": "从一串边出发,沿面的法向、切向或按某一角度生成由直线构成的带状面。用于拔模壁、过渡面和分型面。", + "help.surface.stitch": "沿共用的边将曲面实体缝合在一起,闭合时成为实体。用于将一组曲面变成一个实体。", + "help.surface.sweep": "沿路径移动草图曲线或轮廓生成曲面,无端盖。用于排水槽、饰条等沿路径延伸的薄形状。", + "help.surface.thicken": "为曲面实体赋予厚度,使其成为实体。用于由曲面制作可打印或可加工的零件。", + "help.surface.trim": "用平面、另一个曲面或草图曲线切割曲面,并保留所选的一侧。用于在缝合前将曲面切成所需形状。", + "help.surface.unstitch": "将实体的面分离为各自独立的曲面实体;实体会拆成曲面。用于重新处理实体中的单个面。", + "help.surface.untrim": "去掉面上的孔,或将其外边界恢复为曲面的自然边界。用于在修剪之后找回完整的曲面。", "help.third_party": "第三方许可证", "help.timeline.compute_all": "从头重新计算时间线中的所有特征。用于刷新模型,并查看哪些特征失败。", "help.timeline.delete": "从时间线中删除所选特征;删除草图时,由它生成的特征也会一起删除。用于去掉不再需要的步骤。", + "help.timeline.dependents": "列出会随某个特征一同被删除的特征,以及缺少它就会失败的特征。用于在删除或更改特征之前。", "help.timeline.play": "逐个播放剩余特征,每次重建后暂停半秒。再次运行此命令或按Escape停止;转到末尾会立即跳转。", + "help.view.apply": "将视图转到前视、俯视、等轴测等标准方向,或转到已保存的命名视图。用于从固定角度观察模型。", + "help.view.delete": "删除已保存的命名视图。模型不会改变。", + "help.view.fit": "缩放并平移,使整个模型或所选对象充满视图。用于模型跑出视野时。", + "help.view.grid": "显示或隐藏视口中的网格。用作建模时的尺寸和方向参考。", + "help.view.ground_shadow": "显示或隐藏模型在地面上的阴影。用于更容易判断高度和位置。", + "help.view.home": "将视图转回主视图方向并适合模型。用于快速回到已知的视图。", + "help.view.list": "列出设计中保存的命名视图。用于找到之前保存的视图。", + "help.view.navigate": "按步骤旋转、平移或缩放视图,例如向左转四分之一圈。用于精确移动相机,而不是靠拖动。", + "help.view.navigation_bar": "显示或隐藏视口底部的导航栏。隐藏可腾出空间,显示便于用鼠标使用视图工具。", + "help.view.rename": "为已保存的命名视图改名。视图本身保持不变。", + "help.view.save": "将当前相机位置作为命名视图保存到设计中。用于以后回到同一角度,例如截图。", + "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": "确认删除", @@ -1656,6 +1911,7 @@ "nav.hfss.help": "Alt+左键拖动平移,Shift+Alt+左键拖动缩放,在空白处 Ctrl+左键拖动旋转", "nav.inventor.help": "中键拖动平移,Shift+中键拖动旋转,Ctrl+中键拖动缩放,右键拖动旋转", "nav.maya.help": "Alt+左键拖动旋转,Alt+中键拖动平移,Alt+右键拖动缩放;中键拖动也可平移", + "nav.nx.help": "中键拖动旋转,Shift+中键平移(对应 NX 的中键+右键),Ctrl+中键缩放(对应 NX 的中键+左键);右键拖动不旋转视图", "nav.openscad.help": "在空白处左键拖动旋转,Shift+左键拖动框选,拖动草图几何可编辑;右键或中键拖动平移,Shift+右键或 Shift+中键拖动缩放", "nav.perspective.sketch": "编辑草图时使用正交视图。完成草图后才能切换透视。", "nav.right_drag_orbits.help": "关闭:右键拖动不会移动相机,右键只打开上下文菜单。选择 SolidWorks 鼠标映射时会关闭此项。", @@ -1877,6 +2133,8 @@ "prefs.ctrl_cmd": "Ctrl / Cmd", "prefs.curved_surfaces": "曲面", "prefs.curves": "曲面", + "prefs.dark_parts_shading": "保持深色零件的形状", + "prefs.dark_parts_shading_help": "以线性光照和柔和的天空补光着色视图,使深色面保留渐变而不变成一片黑色", "prefs.dead_zone_note": "斜线区域为死区:此范围内的移动将被忽略。", "prefs.default": "默认", "prefs.design": "设计默认值", @@ -1891,6 +2149,8 @@ "prefs.edges": "边缘与着色", "prefs.extend": "扩展", "prefs.extra_navigation": "在空白处按住Ctrl并用鼠标左键拖动可旋转;按住空格键可平移。", + "prefs.extrude_view_isometric": "打开拉伸时转到等轴测视图", + "prefs.extrude_view_return": "关闭拉伸时回到之前的视图", "prefs.follows_os": "跟随操作系统", "prefs.font": "字体", "prefs.font_fallback": "所选字体缺少的字符会使用内置字体,避免标签显示为空方框。", @@ -1906,6 +2166,7 @@ "prefs.graphics_card_auto": "自动", "prefs.graphics_card_missing": "已保存的显卡不可用;将使用自动选择。", "prefs.graphics_card_restart": "重启 SolidCraft 后生效。WGPU_ADAPTER_NAME 优先于此选择。", + "prefs.graphics_card_unused": "已保存的显卡未用于此窗口;实际使用的显卡显示在“帮助 › 关于”和状态栏中。请尝试“自动”,或将 WGPU_ADAPTER_NAME 设为显卡名称中的唯一部分。", "prefs.graphics_note": "在速度与平滑度之间取舍。每个选项均以示例展示效果。", "prefs.graphics_presets_note": "预设会同时设置以下选项。", "prefs.hide_constraint_glyphs_zoomed_out": "缩小时隐藏约束符号", @@ -1920,6 +2181,8 @@ "prefs.history_sketches_note": "草图:选择“位于其特征下”后,左侧历史在宽度足以显示名称时,会将草图缩进一级,列在由其创建的第一个特征下。底部时间轴始终保持创建顺序。", "prefs.history_sketches_under_feature": "位于其特征下", "prefs.hold_stop_snapping": "按住以停用吸附", + "prefs.home_fit_size": "主视图与适配大小", + "prefs.home_fit_size_note": "主视图、适配、打开的设计或视图导航器切换视图框住模型时,模型占视图的比例,与下方设置是否开启无关。100 %:填满面板、罗盘和导航栏之间的视图。", "prefs.icons_only": "仅图标", "prefs.imported_file": "已导入文件", "prefs.in_browser": "在浏览器中", @@ -1928,13 +2191,20 @@ "prefs.interface_language": "界面语言", "prefs.interface_note": "SolidCraft 的外观和面板布局。", "prefs.interval_minutes": "{n} 分钟", + "prefs.invert_navigator_arrows": "反转视图立方体的旋转箭头", + "prefs.invert_navigator_arrows_note": "立方体周围的箭头按箭头所指的方向转动相机,与 Fusion 和 Inventor 相同:左侧箭头指向右,相机向右转动。关闭:箭头把立方体转向它所在的那一面,因此左侧箭头使相机向左转动。弯曲的翻滚箭头不受影响。", + "prefs.keep_work_on_tab_switch": "切换设计时保持对话框和工具打开", + "prefs.keep_work_on_tab_switch_note": "每个设计标签页保留其打开的对话框或工具(包括其中已选择和输入的内容)以及工具栏选项卡。回到该设计时它们仍在原处。关闭时:切换设计会取消它们并显示“模型”选项卡。", "prefs.labels": "标签", + "prefs.language_system": "系统", "prefs.layout": "布局", "prefs.layout_note": "紧凑模式将面板停靠在窗口边缘。浏览器半透明仅适用于浮动模式。助手面板的位置在助手页面中设置。", "prefs.left": "左侧", "prefs.left_click": "左键单击", "prefs.line_drag_draws_one": "按下并拖动绘制一条直线", "prefs.line_drag_draws_one_note": "在直线工具中,按下、拖动再松开,会从按下处到松开处绘制一条直线并在此结束。单击仍会绘制连续的直线。关闭时:拖动与单击一样,在松开处放置一个点。", + "prefs.line_hover_tangent_arc": "停留在直线的最后一点上以切换到相切圆弧", + "prefs.line_hover_tangent_arc_note": "在直线工具中,画好一段后,先离开连续线的最后一点,再回到该点并让指针保持不动:下一次单击将确定相切圆弧的终点,同样的操作可切回直线。关闭:停留在该点不起作用。", "prefs.linked_designs": "链接设计", "prefs.live_input": "实时输入", "prefs.live_preview": "实时预览", @@ -1958,6 +2228,8 @@ "prefs.mouse_mapping": "鼠标映射", "prefs.navigation": "导航", "prefs.navigation_note": "鼠标、触控板和3D鼠标如何移动视图。", + "prefs.navigator_fit": "视图导航器切换视图后适配模型", + "prefs.navigator_fit_note": "点击立方体的面、角或箭头,或坐标轴指示器的轴时,也会从各个方向都能容纳模型的同一距离(其包围球)注视模型中心。开启期间主视图使用相同的缩放和中心,因此之后选择的视图只改变方向。关闭:视图原地旋转,保持目标点和缩放。", "prefs.network_host_remove": "删除", "prefs.network_locations": "允许设计中的网络位置", "prefs.network_locations_note": "关闭:设计中位于其他计算机 (\\\\server\\share) 上的链接文件,只会从此处列出的服务器打开,或在询问时你允许的服务器打开。", @@ -2006,6 +2278,8 @@ "prefs.render_previous_geometry_budget": "使用先前的渲染预览几何内存预算", "prefs.render_previous_geometry_budget_help": "恢复先前的固定几何内存上限(64位桌面为512 MiB,其他平台为128 MiB),替代基于设备的预算。", "prefs.reports_saved": "已保存 {n} 份", + "prefs.ribbon_click_ends_tool": "单击正在运行的工具的按钮将结束该工具", + "prefs.ribbon_click_ends_tool_note": "单击正在使用的草图工具的高亮功能区按钮会结束该工具,在视图中单击即可再次选择。关闭:单击会重新启动该工具。", "prefs.ribbon_reveal": "在功能区中显示搜索结果", "prefs.ribbon_reveal_note": "将指针悬停在“搜索命令”的结果上时,会显示该命令在功能区中的位置:其选项卡、按钮或面板下拉菜单中的行。从搜索启动的命令会在那里闪烁,从下拉菜单运行的命令会标记其面板。", "prefs.right_click": "右键单击", @@ -2035,6 +2309,8 @@ "prefs.sketch_exit_view.previous": "返回之前的视图", "prefs.sketch_exit_view.unless_rotated": "未旋转时返回", "prefs.sketch_exit_view_note": "“完成草图”、取消或连按两次 Esc 后视图的去向。", + "prefs.sketch_face_activates_part": "在零件的面上绘制草图时激活该零件", + "prefs.sketch_face_activates_part_note": "在另一个组件的实体面上开始的草图会先激活该组件,因此草图属于它,并在浏览器中留在该零件下。之后该零件保持激活。关闭时:草图属于当前激活的组件。", "prefs.sketch_open_look_at": "打开草图时转向正对草图", "prefs.sketch_open_look_at_note": "关闭:创建或编辑草图时视图保持不变。需要时用“朝向”正对草图。", "prefs.sketch_recent_snap": "自动吸附到最近的圆心和中点", @@ -2048,6 +2324,8 @@ "prefs.sketch_tab_always_note": "未打开草图时,草图选项卡也保留在功能区中紧随模型之后。其中的绘图工具会先在所选平面或面上(否则在接下来选取的平面上)创建草图,并在其中准备好该工具。", "prefs.sketch_tracking_guides": "圆心和中点对齐辅助线", "prefs.sketch_tracking_guides_note": "将光标悬停在圆心或直线边的中点,然后水平或垂直移动以显示临时虚线辅助线。", + "prefs.sketch_tracking_points": "绘制时也从端点和角点显示对齐辅助线", + "prefs.sketch_tracking_points_note": "绘图工具等待点时,将光标悬停在直线或圆弧的端点、矩形的角点或独立点上,会显示与圆心或中点相同的临时虚线辅助线。关闭:仅圆心和中点。", "prefs.sketching": "草图", "prefs.snap_drawing_note": "在绘制或拖动草图点时使用。", "prefs.source": "来源", @@ -2064,6 +2342,24 @@ "prefs.status_error": "错误", "prefs.status_selected": "已选择", "prefs.status_warning": "警告", + "prefs.structure.appearance": "外观", + "prefs.structure.appearance_note": "颜色、文字和无障碍。", + "prefs.structure.in_profile": "包含在配置中", + "prefs.structure.layout": "布局", + "prefs.structure.layout_note": "窗口各部分的位置。", + "prefs.structure.look": "外观", + "prefs.structure.measure_pointer": "测量和指针", + "prefs.structure.mouse3d": "3D 鼠标", + "prefs.structure.mouse3d_note": "SpaceMouse 和 SpaceNavigator。", + "prefs.structure.navigation_note": "鼠标和触控板。3D 鼠标有单独的页面。", + "prefs.structure.profile": "配置", + "prefs.structure.profile_edited": "保存后已更改", + "prefs.structure.profile_hover": "保存在设置配置中", + "prefs.structure.scale_percent": "{percent}%", + "prefs.structure.sketch": "草图", + "prefs.structure.tools": "工具", + "prefs.structure.tools_note": "建模工具的行为。", + "prefs.structure.work": "工作", "prefs.supersampling_brief": "以更高分辨率绘制后滤波缩小:更清晰,但负载更高。", "prefs.tab_accent_note": "强调色跟随当前选项卡", "prefs.theme_file": "主题文件", @@ -2075,6 +2371,16 @@ "prefs.theme_follow_off": "关闭", "prefs.theme_follow_stop": "停止跟随", "prefs.theme_import_hint": "单击选择主题文件,拖放到此处,或复制主题后按 Ctrl+V(Mac 上为 Cmd+V)", + "prefs.theme_option_opacity": "不透明度", + "prefs.theme_option_opacity_help": "调整浮动面板的不透明度。最低不透明度限制可确保文字在视口颜色上清晰可读。", + "prefs.theme_option_tab_colours": "选项卡颜色", + "prefs.theme_option_tab_colours_help": "为每个功能区选项卡使用主题的面板和视口颜色。", + "prefs.theme_option_tab_fade_ms": "选项卡过渡", + "prefs.theme_option_tab_fade_ms_help": "切换选项卡时渐变面板和视口颜色。设为零可立即切换。", + "prefs.theme_options_badge": "选项", + "prefs.theme_options_more": "更多 · {count}", + "prefs.theme_options_reset": "重置", + "prefs.theme_options_unavailable": "此设备不支持", "prefs.theme_paste": "粘贴主题", "prefs.theme_paste_none": "剪贴板中没有 SolidCraft 主题", "prefs.timeline_half_hour": "−30 分钟", @@ -2109,6 +2415,8 @@ "prefs.viewport_background_theme": "主题渐变", "prefs.viewport_section_status": "显示视口剖面状态", "prefs.viewport_section_status_help": "在视口中显示剖面设置。按 Escape 或单击活动的功能区按钮可关闭剖面。禁用以保留原有剖面控件。", + "prefs.viewport_status_details": "视口实时详情", + "prefs.viewport_status_details_help": "在标准详情卡片中编辑当前分析和剖面设置。禁用后恢复只读分析图例和剖面对话框。", "prefs.viewport_status_summary": "汇总视口状态项", "prefs.viewport_status_summary_help": "将三个或更多视口状态项折叠为一个汇总徽章。禁用后,所有项目仍显示在完整的一行中。", "prefs.wheel": "滚轮", @@ -3549,6 +3857,7 @@ "sketch_text.formula": "以 = 开头即可用参数组成文字:='W' + number(Width)", "sketch_text.italic": "斜体", "sketch_text.loading_fonts": "正在查找已安装字体…", + "sketch_text.no_bold": "内置字体没有粗体字形:请选择已安装的字体", "sketch_text.on_path": "文字沿路径排列:由曲线放置", "sketch_text.path_clear": "将文字移离其曲线", "sketch_text.path_hint": "文字所跟随的草图曲线:先单击此处,再选择曲线", @@ -3757,6 +4066,7 @@ "ui.agent_chat_auth_terminal": "在终端中运行以下命令,然后再次点击连接:", "ui.agent_chat_auth_title": "登录智能体", "ui.agent_chat_cancel": "取消", + "ui.agent_chat_cannot_start": "已安装,但无法启动:{error}", "ui.agent_chat_changes": "{n} 项更改,一步撤销", "ui.agent_chat_changes_n": "{n} 项更改", "ui.agent_chat_changes_of": "更改 {n}/{limit}", @@ -3849,6 +4159,7 @@ "ui.agent_chat_queued_stopped": "已排队(已停止,未发送)", "ui.agent_chat_refresh": "刷新", "ui.agent_chat_refused": "不允许智能体执行:{reason}", + "ui.agent_chat_reinstall": "{agent} 已安装({program}),但 SolidCraft 检查时它没有启动:{error}\n它的安装可能不完整,或是为另一种计算机准备的。请用 `{install}` 重新安装,确认它能在终端中显示版本号,然后点击刷新。", "ui.agent_chat_remove": "从队列中移除", "ui.agent_chat_remove_file": "从这条消息中移除", "ui.agent_chat_remove_imported": "从这条消息中移除;零件仍保留在设计中(撤销可将其删除)", @@ -3912,12 +4223,17 @@ "ui.ai_copy_suffix": "副本", "ui.ai_custom_url": "自定义 URL", "ui.ai_default": "默认", + "ui.ai_edit": "编辑", + "ui.ai_edit_agent_title": "编辑智能体", + "ui.ai_edit_gone": "此智能体已被移除,没有可保存的内容。", + "ui.ai_edit_tip": "更改此智能体的名称、命令或端点。已保存的密钥保持隐藏。", "ui.ai_http_busy": "端点繁忙或已达上限,请稍后再试", "ui.ai_http_key": "端点拒绝了密钥:请在“首选项 > AI”中检查", "ui.ai_http_not_found": "找不到该端点或模型:请检查基础 URL 和模型名称", "ui.ai_http_too_large": "对话对此端点来说过大,请开始新的聊天", "ui.ai_key": "API 密钥", "ui.ai_key_hint": "可选;保存在系统钥匙串中", + "ui.ai_key_keep_hint": "留空以保留已保存的密钥", "ui.ai_key_needs_https": "密钥只会通过 HTTPS 发送,或发送到本机。", "ui.ai_key_note": "保存在系统钥匙串中,绝不保存在 SolidCraft 的设置里。本地服务器可留空。", "ui.ai_keychain_error": "系统钥匙串拒绝:{error}", @@ -3944,11 +4260,15 @@ "ui.ai_permissions": "权限", "ui.ai_preset": "预设", "ui.ai_protocol": "协议", + "ui.ai_reasoning_effort": "推理强度", + "ui.ai_reasoning_effort_field_hint": "留空 = 服务商默认", + "ui.ai_reasoning_effort_hint": "可选。将作为 `reasoning_effort` 发送给端点;各服务商接受的值不同(例如 `low`、`high` 或 `max`)。您在此设置的值绝不会被 SolidCraft 替换。留空则不发送任何内容,沿用服务商默认值。", "ui.ai_remove": "移除", "ui.ai_request_timed_out": "模型用时超过 {seconds} 秒。可在“首选项 › 助手”中调高此端点的请求超时。", "ui.ai_request_timeout": "请求超时", "ui.ai_request_timeout_hint": "对此端点的一次模型请求最多可用的时间,30 秒到 1 小时。本地模型较慢时请调高。", "ui.ai_reset": "重置", + "ui.ai_save": "保存", "ui.ai_settings": "AI 设置…", "ui.ai_show_features": "显示 AI 功能", "ui.ai_show_features_note": "智能体聊天:一个面板,由你已在使用的编程智能体或你选择的模型端点处理打开的设计。关闭后:没有 AI 按钮、快捷键、菜单项或面板,也不会查找或启动任何程序。", @@ -4108,6 +4428,7 @@ "ui.dark": "深色", "ui.default_modelling_orientation": "默认建模方向", "ui.delete": "删除", + "ui.delete_select_first": "请先选择要删除的内容:特征、实体、组件或草图几何", "ui.depth": "深度", "ui.design": "设计", "ui.design_properties.author": "作者", @@ -4122,6 +4443,40 @@ "ui.design_properties.project": "项目", "ui.design_properties.revision": "版本", "ui.design_saved": "已保存到 {path}", + "ui.details_access_caption": "面显示刀具能否从所选方向到达它们。", + "ui.details_angle": "角度", + "ui.details_continuity_caption": "编辑所选接缝。其他接缝保留各自的设置。", + "ui.details_continuity_stale": "先前的几何体:请重新运行分析,以编辑此接缝或使其适合视图。", + "ui.details_copy_list": "复制列表", + "ui.details_curvature": "曲率容差", + "ui.details_curvature_caption": "面按其局部曲率半径着色。", + "ui.details_direction": "刀具方向", + "ui.details_draft_caption": "面按其与脱模方向的夹角着色。", + "ui.details_edit_seam": "编辑接缝", + "ui.details_fixture": "界面组件示例 — 仅含示例数据", + "ui.details_fixture_caption": "使用说明性数值的界面组件示例;这不是模型的分析结果。", + "ui.details_fixture_problem": "含问题的检查结果示例", + "ui.details_fixture_readout": "测量值示例", + "ui.details_flip": "翻转", + "ui.details_gap": "间隙", + "ui.details_hatch": "为切割面添加剖面线", + "ui.details_jump": "曲率突变", + "ui.details_key": "图例", + "ui.details_offset": "偏移", + "ui.details_plane": "平面", + "ui.details_pull": "脱模方向", + "ui.details_radius": "半径", + "ui.details_readout": "读数", + "ui.details_samples": "采样数", + "ui.details_seam": "{body} · 边 {edge}", + "ui.details_settings": "设置", + "ui.details_sharp_gap": "尖锐或有间隙", + "ui.details_show_all": "显示全部 {count} 项", + "ui.details_smooth": "光顺", + "ui.details_stripes": "条纹", + "ui.details_target": "使几何体适合视图", + "ui.details_worst_seams": "连续性最差的接缝", + "ui.details_zebra_caption": "反射条纹显示面之间连接的光顺程度。", "ui.diameter": "直径", "ui.dimension": "尺寸", "ui.direct_interaction": "直接交互", @@ -4166,6 +4521,7 @@ "ui.edit_in_place_saved_elsewhere": "零件已保存到其他文件;装配体仍链接 {name}", "ui.edit_in_place_wait": "请等待设计中正在运行的操作完成", "ui.edit_section_analysis": "编辑截面分析", + "ui.edit_sketch_empty": "没有可编辑的草图", "ui.ellipse": "椭圆", "ui.emboss_wrap_note": "与受支持圆柱相切的正交线轮廓会自动缠绕。选择一个壁以指定目标。", "ui.end_radius": "结束半径", @@ -4176,12 +4532,15 @@ "ui.equation_explicit": "显式", "ui.equation_form": "形式", "ui.equation_note": "变量是不带单位的数:sin(t) 按弧度计算。不带单位的结果采用设计的单位。", + "ui.equation_note_range": "变量与其范围一样是长度:可与长度参数混用,用于 sin() 时需除以一个长度。没有单位的结果采用设计的单位。", "ui.equation_parametric": "参数式", + "ui.equation_variable_unit": "变量使用范围的单位", "ui.equation_x_of_t": "x(t)", "ui.equation_y_of_t": "y(t)", "ui.equation_y_of_x": "y(x)", "ui.equivalent_radius": "等效半径", "ui.expand": "展开", + "ui.explode_needs_two_components": "爆炸图会将组件彼此分开,因此设计中需要两个或更多组件(新建组件、插入设计或由实体创建组件)", "ui.exportDxf": "导出 DXF…", "ui.export_ellipsis_keymap": "导出…", "ui.export_keymap_tip": "将按键、S 框收藏和工具栏导出为文件", @@ -4350,12 +4709,14 @@ "ui.joints": "关节", "ui.keyboard_shortcuts": "键盘快捷键", "ui.language": "语言", + "ui.lasso_select": "套索(徒手圈选:选择完全在圈内的对象)", "ui.layout_compact": "紧凑", "ui.layout_floating": "浮动", "ui.length": "长度", "ui.length_decimals": "长度小数位", "ui.light": "浅色", "ui.line": "直线", + "ui.line_arc_hint": "相切圆弧:单击圆弧的终点。再次停留在最后一点可切回直线;右键单击或按 Esc 结束", "ui.link_copies": "链接相同副本", "ui.link_copies_keep": "保留", "ui.link_copies_link": "链接", @@ -4444,6 +4805,7 @@ "ui.part_size_shared": "重新生成零件并替换额外添加的特征。此零部件的所有实例都会更新。", "ui.paste": "粘贴", "ui.path": "路径", + "ui.pattern_object_type_tip": "实体:在视图或浏览器中选取的实心形状,每个都会被整体复制。\n特征:历史中的步骤,例如拉伸或孔,在历史中或在其生成的面上选取;该步骤会被重复。\n草图两者都不是:请用草图自身的阵列或镜像重复其曲线,或先将其拉伸。", "ui.pattern_on_path": "路径阵列", "ui.perforate_margin_default": "与直径相同", "ui.perforate_pitch": "孔距", @@ -4509,6 +4871,7 @@ "ui.properties.inertia_y": "Iyx, Iyy, Iyz", "ui.properties.inertia_z": "Izx, Izy, Izz", "ui.properties.measuring": "正在测量…", + "ui.properties.measuring_bodies": "正在测量… 第 {done} 个,共 {total} 个实体", "ui.properties.measuring_failed": "测量在完成前已停止。", "ui.properties.principal_axis": "主轴 {number}", "ui.properties.principal_moments": "主惯性矩", diff --git a/assets/shaders/viewport.wgsl b/assets/shaders/viewport.wgsl index 4715d9edfd..140b23f6b4 100644 --- a/assets/shaders/viewport.wgsl +++ b/assets/shaders/viewport.wgsl @@ -20,6 +20,7 @@ struct U { sel: vec4, // xyz a whole selected body's colour before lighting product_lights_meta: vec4, // explicit preview flag and bounded count product_lights: array,84>, // position/kind, radiance, axis/cone, attenuation + sky: vec4, // xyz = world up; w = 1: shade in linear light with a sky fill (#7974) }; fn reversed() -> bool { @@ -423,8 +424,13 @@ fn analysis(i: TOut, k: f32) -> vec4 { let r = reflect(-v, n); var side = cross(v, vec3(0.0, 0.0, 1.0)); if (length(side) < 1e-3) { side = vec3(1.0, 0.0, 0.0); } - let s = sin(dot(r, normalize(side)) * u.ana.y * 3.14159265); - let c = select(0.08, 0.95, s > 0.0); + let x = dot(r, normalize(side)); + let s = sin(x * u.ana.y * 3.14159265); + // A face whose normal lies in the upright plane through the view (every horizontal + // face) reflects straight ahead: its sideways part is zero, on the edge between a black + // and a white stripe, and rounding picked one or the other from frame to frame while + // the view turned (#2153). Straight ahead is white. + let c = select(0.08, 0.95, s > 0.0 || abs(x) < 1e-4); return out_color(vec4(vec3(c), 1.0)); } if (u.ana.x > 4.5) { @@ -545,6 +551,31 @@ fn lit(i: TOut, visibility: f32) -> vec4 { return lit_color(i, visibility, i.c); } +/// sRGB to linear light, exact (the straight segment too, which `to_linear` leaves out). +fn srgb_decode(c: vec3) -> vec3 { + return select(c / 12.92, pow((c + vec3(0.055)) / vec3(1.055), vec3(2.4)), c > vec3(0.04045)); +} + +fn srgb_encode(c: vec3) -> vec3 { + let l = clamp(c, vec3(0.0), vec3(1.0)); + return select(l * 12.92, 1.055 * pow(l, vec3(1.0 / 2.4)) - vec3(0.055), l > vec3(0.0031308)); +} + +/// The studio light summed in linear light, encoded once (#7974): the key and the eye fill as in +/// [`lit_color`], over a sky fill that grows as the face turns up, and the 4% every dielectric +/// reflects of that sky (a little more toward grazing), so a near-black face keeps a gradient. The sky +/// and the fill are what ambient occlusion (`visibility`) takes away. sRGB out. +fn sky_lit(base: vec3, n: vec3, diff: f32, fill: f32, visibility: f32) -> vec3 { + let up = dot(n, normalize(u.sky.xyz)) * 0.5 + 0.5; + let sky = mix(0.18, 0.42, up); + // Schlick's rise toward grazing, tempered so mid-tones only gain a soft rim. + let fresnel = 0.04 + 0.2 * pow(1.0 - fill, 5.0); + let albedo = srgb_decode(base); + // Scaled so light and mid-grey faces land near where the sRGB shading puts them. + let light = albedo * (0.41 * diff + (0.66 * sky + 0.2 * fill) * visibility) + vec3(fresnel * sky * visibility); + return srgb_encode(light); +} + /// [`lit`] in a colour of its own: a whole highlighted body is lit in its colour lifted by the /// hover amount, or in the selection colour, from the model's own plan triangles. fn lit_color(i: TOut, visibility: f32, base: vec4) -> vec4 { @@ -571,6 +602,9 @@ fn lit_color(i: TOut, visibility: f32, base: vec4) -> vec4 { let fill = max(dot(n, v), 0.0); let h = normalize(key + v); let spec = pow(max(dot(n, h), 0.0), 40.0) * 0.25; + if u.sky.w > 0.5 { + return out_color(vec4(min(sky_lit(base.rgb, n, diff, fill, visibility) + vec3(spec), vec3(1.0)), base.a)); + } let k = 0.42 + 0.38 * diff + 0.25 * fill - (0.42 + 0.25 * fill) * (1.0 - visibility); return out_color(vec4(min(base.rgb * k + vec3(spec), vec3(1.0)), base.a)); } diff --git a/assets/styles/ui/themes/gruvbox-dark-hard.json b/assets/styles/ui/themes/gruvbox-dark-hard.json deleted file mode 100644 index d1f9b019a6..0000000000 --- a/assets/styles/ui/themes/gruvbox-dark-hard.json +++ /dev/null @@ -1,171 +0,0 @@ -{ - "order": 20, - "provenance": "Original SolidCraft mapping of Gruvbox (MIT palette)", - "format": "solidcraft-theme/1", - "name": "Gruvbox Dark Hard", - "base": "dark", - "colors": { - "app_bar": "#141617", - "toolbar": "#1d2021", - "panel": "#1d2021", - "panel_header": "#3c3836", - "border": "#504945", - "text": "#ebdbb2", - "text_dim": "#a89984", - "accent": "#83a598", - "accent_soft": "#414f4b", - "sketch_accent": "#a1a322", - "error": "#fb4934", - "warning": "#fabd2f", - "icon": "#d5c4a1", - "timeline": "#141617", - "hover": "#46403e", - "sel_face": "#458588", - "sel_edge": "#83a598", - "sel_edge_rim": "#aec4bc", - "sel_profile": "#076678", - "sel_profile_edge": "#458588", - "hover_profile": "#83a59830", - "hover_profile_edge": "#83a598", - "sel_vertex": "#83a598", - "origin_plane": "#fe801950", - "origin_plane_edge": "#d65d0ec8", - "origin_plane_hover": "#83a59896", - "origin_point": "#d5c4a1", - "axis_x": "#fb4934", - "axis_y": "#b8bb26", - "axis_z": "#83a598", - "construction_plane": "#fe801940", - "construction_geom": "#e97926", - "body": "#a89984", - "body_edge": "#141617", - "manipulator": "#83a598", - "preview_add": "#83a598", - "preview_new": "#fabd2f", - "preview_sketch": "#b8bb26", - "preview_cut": "#fb49346e", - "cube_face": "#ebdbb2", - "cube_border": "#665c54", - "cube_text": "#282828", - "cube_hover": "#83a598", - "viewport_top": "#2c2c2c", - "viewport_bottom": "#2c2c2c", - "grid": "#443f3c", - "grid_major": "#504945", - "body_selected": "#83a598", - "sketch": "#83a598", - "sketch_fixed": "#ebdbb2", - "sketch_halo": "#141617", - "sketch_locked": "#b8bb26", - "dimension": "#fabd2f", - "dimension_driven": "#b57614", - "sketch_point": "#ebdbb2", - "sketch_point_free": "#83a598", - "dialog_bg": "#3c3836", - "dialog_border": "#504945", - "chip": "#458588", - "section_cap": "#d65d0e", - "profile_fill": "#83a59828", - "glyph_bg": "#2c2c2c", - "glyph_edge": "#fe614c", - "glyph_text": "#fe614c", - "overlay": "#1d2021f0", - "rubber_band": "#83a598", - "app_bar_text": "#ebdbb2", - "app_bar_hover": "#ebdbb21c", - "timeline_item": "#3c3836", - "timeline_item_error": "#63221f", - "timeline_marker": "#bdae93", - "field": "#141617", - "box_window": "#83a598", - "box_crossing": "#b8bb26", - "sketch_projected": "#d3869b", - "sketch_lost": "#fb4934", - "sketch_construction": "#fe8019", - "sketch_centerline": "#d65d0e", - "bend_up": "#b8bb26", - "bend_down": "#fe8019", - "interference": "#fb4934", - "icons.agent": "#d3869b", - "surface_fill": "#fe9755", - "surface_soft": "#382c20", - "surface_ink": "#fe8019", - "surface_accent": "#e97926", - "mesh_fill": "#e1ab32", - "mesh_soft": "#383323", - "mesh_ink": "#fabd2f", - "mesh_accent": "#c39429", - "form_fill": "#ea9bb0", - "form_soft": "#332c30", - "form_tab_ink": "#d3869b", - "form_accent": "#cf8297", - "sheet_fill": "#93c681", - "sheet_soft": "#2b332c", - "sheet_ink": "#8ec07c", - "render_fill": "#fc9483", - "render_soft": "#382523", - "render_ink": "#fb5d4a", - "render_accent": "#fe614c", - "sketch_soft": "#303322", - "sketch_ink": "#b8bb26", - "form_ink": "#ebdbb2", - "form_dim": "#bdae93", - "form_primary": "#83a598", - "form_error": "#fb4934", - "form_error_fill": "#63221f", - "form_success": "#b8bb26", - "form_success_fill": "#343a28", - "dialog_body": "#1d2021", - "dialog_footer": "#3c3836", - "dialog_rule": "#504945", - "control_fill": "#141617", - "control_border": "#7c6f64", - "dialog_label": "#ebdbb2", - "selected_ink": "#ebdbb2", - "selected_bg": "#414f4b", - "on_accent": "#1d2021" - }, - "icon_palette": { - "ink": "#141617", - "line": "#d5c4a1", - "passive-1": "#c5c0b8", - "passive-2": "#9a948d", - "passive-3": "#706b64", - "guide": "#a89984", - "solid": [ - "#b8dbcd", - "#9bbeb1", - "#83a598" - ], - "surface": [ - "#fcc4a3", - "#fe9755", - "#e97926" - ], - "mesh": [ - "#fbca67", - "#e1ab32", - "#c39429" - ], - "form": [ - "#fbbfce", - "#ea9bb0", - "#cf8297" - ], - "sketch": [ - "#d7db50", - "#babd2a", - "#a1a322" - ], - "constraint": [ - "#fcc2b7", - "#fc9483", - "#fe614c" - ], - "construction": [ - "#fcc4a3", - "#fe9755", - "#e97926" - ] - } -} diff --git a/assets/styles/ui/themes/gruvbox-dark-soft.json b/assets/styles/ui/themes/gruvbox-dark-soft.json deleted file mode 100644 index 847b57a031..0000000000 --- a/assets/styles/ui/themes/gruvbox-dark-soft.json +++ /dev/null @@ -1,172 +0,0 @@ -{ - "order": 30, - "provenance": "Original SolidCraft mapping of Gruvbox (MIT palette)", - "format": "solidcraft-theme/1", - "name": "Gruvbox Dark Soft", - "base": "dark", - "colors": { - "app_bar": "#1d2021", - "toolbar": "#32302f", - "panel": "#32302f", - "panel_header": "#3c3836", - "border": "#504945", - "text": "#ebdbb2", - "text_dim": "#a89984", - "accent": "#83a598", - "accent_soft": "#4e5954", - "selected_ink": "#ebdbb2", - "sketch_accent": "#a1a322", - "error": "#fb4934", - "warning": "#fabd2f", - "icon": "#d5c4a1", - "timeline": "#1d2021", - "hover": "#46403e", - "sel_face": "#458588", - "sel_edge": "#83a598", - "sel_edge_rim": "#aec4bc", - "sel_profile": "#076678", - "sel_profile_edge": "#458588", - "hover_profile": "#83a59830", - "hover_profile_edge": "#83a598", - "sel_vertex": "#83a598", - "origin_plane": "#fe801950", - "origin_plane_edge": "#d65d0ec8", - "origin_plane_hover": "#83a59896", - "origin_point": "#d5c4a1", - "axis_x": "#fb4934", - "axis_y": "#b8bb26", - "axis_z": "#83a598", - "construction_plane": "#fe801940", - "construction_geom": "#e97926", - "body": "#a89984", - "body_edge": "#1d2021", - "manipulator": "#83a598", - "preview_add": "#83a598", - "preview_new": "#fabd2f", - "preview_sketch": "#b8bb26", - "preview_cut": "#fb49346e", - "cube_face": "#ebdbb2", - "cube_border": "#665c54", - "cube_text": "#282828", - "cube_hover": "#83a598", - "viewport_top": "#373432", - "viewport_bottom": "#373432", - "grid": "#443f3c", - "grid_major": "#504945", - "body_selected": "#83a598", - "sketch": "#83a598", - "sketch_fixed": "#ebdbb2", - "sketch_halo": "#1d2021", - "sketch_locked": "#b8bb26", - "dimension": "#fabd2f", - "dimension_driven": "#b57614", - "sketch_point": "#ebdbb2", - "sketch_point_free": "#83a598", - "dialog_bg": "#3c3836", - "dialog_border": "#504945", - "chip": "#458588", - "section_cap": "#d65d0e", - "profile_fill": "#83a59828", - "glyph_bg": "#373432", - "glyph_edge": "#fe614c", - "glyph_text": "#fe614c", - "overlay": "#32302ff0", - "rubber_band": "#83a598", - "app_bar_text": "#ebdbb2", - "app_bar_hover": "#ebdbb21c", - "timeline_item": "#3c3836", - "timeline_item_error": "#702b28", - "timeline_marker": "#bdae93", - "field": "#1d2021", - "box_window": "#83a598", - "box_crossing": "#b8bb26", - "sketch_projected": "#d3869b", - "sketch_lost": "#fb4934", - "sketch_construction": "#fe8019", - "sketch_centerline": "#d65d0e", - "bend_up": "#b8bb26", - "bend_down": "#fe8019", - "interference": "#fb4934", - "icons.agent": "#d3869b", - "surface_fill": "#fe9755", - "surface_soft": "#4a3a2c", - "surface_ink": "#fe8b2e", - "surface_accent": "#e97926", - "mesh_fill": "#e1ab32", - "mesh_soft": "#4a412f", - "mesh_ink": "#fabd2f", - "mesh_accent": "#c39429", - "form_fill": "#ea9bb0", - "form_soft": "#453a3c", - "form_tab_ink": "#d996a8", - "form_accent": "#cf8297", - "sheet_fill": "#93c681", - "sheet_soft": "#3d4138", - "sheet_ink": "#8ec07c", - "render_fill": "#fc9483", - "render_soft": "#4a3330", - "render_ink": "#fc7e6f", - "render_accent": "#fe614c", - "sketch_soft": "#42412e", - "sketch_ink": "#b8bb26", - "form_ink": "#ebdbb2", - "form_dim": "#bdae93", - "form_primary": "#83a598", - "form_error": "#fb4934", - "form_error_fill": "#702b28", - "form_success": "#b8bb26", - "form_success_fill": "#343a28", - "dialog_body": "#32302f", - "dialog_footer": "#3c3836", - "dialog_rule": "#504945", - "control_fill": "#1d2021", - "control_border": "#7c6f64", - "dialog_label": "#ebdbb2", - "selected_ink": "#ebdbb2", - "selected_bg": "#4e5954", - "on_accent": "#1d2021" - }, - "icon_palette": { - "ink": "#1d2021", - "line": "#d5c4a1", - "passive-1": "#c5c0b8", - "passive-2": "#9a948d", - "passive-3": "#706b64", - "guide": "#a89984", - "solid": [ - "#b8dbcd", - "#9bbeb1", - "#83a598" - ], - "surface": [ - "#fcc4a3", - "#fe9755", - "#e97926" - ], - "mesh": [ - "#fbca67", - "#e1ab32", - "#c39429" - ], - "form": [ - "#fbbfce", - "#ea9bb0", - "#cf8297" - ], - "sketch": [ - "#d7db50", - "#babd2a", - "#a1a322" - ], - "constraint": [ - "#fcc2b7", - "#fc9483", - "#fe614c" - ], - "construction": [ - "#fcc4a3", - "#fe9755", - "#e97926" - ] - } -} diff --git a/assets/styles/ui/themes/gruvbox-dark.json b/assets/styles/ui/themes/gruvbox-dark.json index 9c73534e0e..11ab4c6f8c 100644 --- a/assets/styles/ui/themes/gruvbox-dark.json +++ b/assets/styles/ui/themes/gruvbox-dark.json @@ -122,7 +122,6 @@ "control_fill": "#1d2021", "control_border": "#7c6f64", "dialog_label": "#ebdbb2", - "selected_ink": "#ebdbb2", "selected_bg": "#48544f", "on_accent": "#1d2021" }, @@ -168,5 +167,124 @@ "#fe9755", "#e97926" ] - } + }, + "options": [ + { + "id": "contrast", + "type": "choice", + "default": "standard", + "label": { + "de": "Kontrast", + "en": "Contrast", + "ja": "コントラスト", + "ko": "대비", + "zh": "对比度" + }, + "help": { + "de": "Wähle die weiche, normale oder harte Gruvbox-Palette.", + "en": "Choose the soft, standard or hard Gruvbox palette.", + "ja": "Gruvboxのソフト・標準・ハードのパレットを選びます。", + "ko": "Gruvbox의 부드러운, 표준 또는 강한 팔레트를 선택합니다.", + "zh": "选择柔和、标准或强对比的 Gruvbox 配色。" + }, + "values": [ + { + "id": "soft", + "label": { + "de": "Weich", + "en": "Soft", + "ja": "ソフト", + "ko": "부드럽게", + "zh": "柔和" + } + }, + { + "id": "standard", + "label": { + "de": "Standard", + "en": "Standard", + "ja": "標準", + "ko": "표준", + "zh": "标准" + } + }, + { + "id": "hard", + "label": { + "de": "Hart", + "en": "Hard", + "ja": "ハード", + "ko": "강하게", + "zh": "强对比" + } + } + ] + } + ], + "overlays": [ + { + "when": { + "opt": "contrast", + "eq": "soft" + }, + "colors": { + "toolbar": "#32302f", + "panel": "#32302f", + "accent_soft": "#4e5954", + "viewport_top": "#373432", + "viewport_bottom": "#373432", + "glyph_bg": "#373432", + "overlay": "#32302ff0", + "timeline_item_error": "#702b28", + "surface_soft": "#4a3a2c", + "surface_ink": "#fe8b2e", + "mesh_soft": "#4a412f", + "form_soft": "#453a3c", + "form_tab_ink": "#d996a8", + "sheet_soft": "#3d4138", + "render_soft": "#4a3330", + "render_ink": "#fc7e6f", + "sketch_soft": "#42412e", + "form_error_fill": "#702b28", + "dialog_body": "#32302f", + "selected_bg": "#4e5954" + } + }, + { + "when": { + "opt": "contrast", + "eq": "hard" + }, + "colors": { + "app_bar": "#141617", + "toolbar": "#1d2021", + "panel": "#1d2021", + "accent_soft": "#414f4b", + "timeline": "#141617", + "body_edge": "#141617", + "viewport_top": "#2c2c2c", + "viewport_bottom": "#2c2c2c", + "sketch_halo": "#141617", + "glyph_bg": "#2c2c2c", + "overlay": "#1d2021f0", + "timeline_item_error": "#63221f", + "field": "#141617", + "surface_soft": "#382c20", + "mesh_soft": "#383323", + "form_soft": "#332c30", + "form_tab_ink": "#d3869b", + "sheet_soft": "#2b332c", + "render_soft": "#382523", + "render_ink": "#fb5d4a", + "sketch_soft": "#303322", + "form_error_fill": "#63221f", + "dialog_body": "#1d2021", + "control_fill": "#141617", + "selected_bg": "#414f4b" + }, + "icon_palette": { + "ink": "#141617" + } + } + ] } diff --git a/assets/styles/ui/themes/gruvbox-light-hard.json b/assets/styles/ui/themes/gruvbox-light-hard.json deleted file mode 100644 index 75e669b517..0000000000 --- a/assets/styles/ui/themes/gruvbox-light-hard.json +++ /dev/null @@ -1,171 +0,0 @@ -{ - "order": 50, - "provenance": "Original SolidCraft mapping of Gruvbox (MIT palette)", - "format": "solidcraft-theme/1", - "name": "Gruvbox Light Hard", - "base": "light", - "colors": { - "app_bar": "#3c3836", - "toolbar": "#f9f5d7", - "panel": "#f9f5d7", - "panel_header": "#ebdbb2", - "border": "#d5c4a1", - "text": "#3c3836", - "text_dim": "#7c6f64", - "accent": "#076678", - "accent_soft": "#c9d8c4", - "sketch_accent": "#625e15", - "error": "#9d0006", - "warning": "#b57614", - "icon": "#504945", - "timeline": "#ebdbb2", - "hover": "#f1e5c1", - "sel_face": "#458588", - "sel_edge": "#076678", - "sel_edge_rim": "#83a598", - "sel_profile": "#076678", - "sel_profile_edge": "#458588", - "hover_profile": "#45858830", - "hover_profile_edge": "#458588", - "sel_vertex": "#076678", - "origin_plane": "#fe80195e", - "origin_plane_edge": "#d65d0ec8", - "origin_plane_hover": "#45858896", - "origin_point": "#d5c4a1", - "axis_x": "#cc241d", - "axis_y": "#98971a", - "axis_z": "#458588", - "construction_plane": "#fe801946", - "construction_geom": "#9b3407", - "body": "#bdae93", - "body_edge": "#3c3836", - "manipulator": "#076678", - "preview_add": "#458588", - "preview_new": "#d79921", - "preview_sketch": "#98971a", - "preview_cut": "#cc241d6e", - "cube_face": "#fbf1c7", - "cube_border": "#7c6f64", - "cube_text": "#3c3836", - "cube_hover": "#83a598", - "viewport_top": "#fbf8e3", - "viewport_bottom": "#ebdbb2", - "grid": "#d5c4a1", - "grid_major": "#bdae93", - "body_selected": "#83a598", - "sketch": "#076678", - "sketch_fixed": "#282828", - "sketch_halo": "#fbf8e3", - "sketch_locked": "#79740e", - "dimension": "#1f5c40", - "dimension_driven": "#4f7d64", - "sketch_point": "#282828", - "sketch_point_free": "#076678", - "dialog_bg": "#f9f5d7", - "dialog_border": "#d5c4a1", - "chip": "#458588", - "section_cap": "#d65d0e", - "profile_fill": "#fabd2f46", - "glyph_bg": "#fcfaeb", - "glyph_edge": "#a91714", - "glyph_text": "#a91714", - "overlay": "#f9f5d7eb", - "rubber_band": "#076678", - "app_bar_text": "#fbf1c7", - "app_bar_hover": "#fbf1c71c", - "timeline_item": "#f9f5d7", - "timeline_item_error": "#f0cbb2", - "timeline_marker": "#504945", - "field": "#fcfaeb", - "box_window": "#076678", - "box_crossing": "#79740e", - "sketch_projected": "#8f3f71", - "sketch_lost": "#9d0006", - "sketch_construction": "#af3a03", - "sketch_centerline": "#d65d0e", - "bend_up": "#79740e", - "bend_down": "#af3a03", - "interference": "#cc241d", - "icons.agent": "#8f3f71", - "surface_fill": "#df663c", - "surface_soft": "#f3e6c6", - "surface_ink": "#af3a03", - "surface_accent": "#9b3407", - "mesh_fill": "#c08024", - "mesh_soft": "#f4ebc7", - "mesh_ink": "#915e10", - "mesh_accent": "#7b5112", - "form_fill": "#c46fa2", - "form_soft": "#f1e6cf", - "form_tab_ink": "#8f3f71", - "form_accent": "#893a6c", - "sheet_fill": "#659f7a", - "sheet_soft": "#eaebcd", - "sheet_ink": "#3d7252", - "render_fill": "#e85a4d", - "render_soft": "#f2e1c6", - "render_ink": "#9d0006", - "render_accent": "#a91714", - "sketch_soft": "#efebc7", - "sketch_ink": "#6e6a0d", - "form_ink": "#3c3836", - "form_dim": "#665c54", - "form_primary": "#076678", - "form_error": "#9d0006", - "form_error_fill": "#f0cbb2", - "form_success": "#427b58", - "form_success_fill": "#dae3b6", - "dialog_body": "#f9f5d7", - "dialog_footer": "#ebdbb2", - "dialog_rule": "#d5c4a1", - "control_fill": "#fcfaeb", - "control_border": "#a89984", - "dialog_label": "#3c3836", - "selected_ink": "#3c3836", - "selected_bg": "#c9d8c4", - "on_accent": "#fbf1c7" - }, - "icon_palette": { - "ink": "#3c3836", - "line": "#504945", - "passive-1": "#e9e3df", - "passive-2": "#b8b3af", - "passive-3": "#8a8581", - "guide": "#7c6f64", - "solid": [ - "#87d5e9", - "#4e9caf", - "#076678" - ], - "surface": [ - "#feb299", - "#df663c", - "#9b3407" - ], - "mesh": [ - "#fcb864", - "#c08024", - "#7b5112" - ], - "form": [ - "#fbaad9", - "#c46fa2", - "#893a6c" - ], - "sketch": [ - "#d0cc73", - "#989439", - "#625e15" - ], - "constraint": [ - "#fab3a8", - "#e85a4d", - "#a91714" - ], - "construction": [ - "#feb299", - "#df663c", - "#9b3407" - ] - } -} diff --git a/assets/styles/ui/themes/gruvbox-light-soft.json b/assets/styles/ui/themes/gruvbox-light-soft.json deleted file mode 100644 index 24c372901f..0000000000 --- a/assets/styles/ui/themes/gruvbox-light-soft.json +++ /dev/null @@ -1,171 +0,0 @@ -{ - "order": 60, - "provenance": "Original SolidCraft mapping of Gruvbox (MIT palette)", - "format": "solidcraft-theme/1", - "name": "Gruvbox Light Soft", - "base": "light", - "colors": { - "app_bar": "#3c3836", - "toolbar": "#f2e5bc", - "panel": "#f2e5bc", - "panel_header": "#ebdbb2", - "border": "#d5c4a1", - "text": "#3c3836", - "text_dim": "#7c6f64", - "accent": "#076678", - "accent_soft": "#c3ccae", - "sketch_accent": "#625e15", - "error": "#9d0006", - "warning": "#b57614", - "icon": "#504945", - "timeline": "#ebdbb2", - "hover": "#eedfb6", - "sel_face": "#458588", - "sel_edge": "#076678", - "sel_edge_rim": "#83a598", - "sel_profile": "#076678", - "sel_profile_edge": "#458588", - "hover_profile": "#45858830", - "hover_profile_edge": "#458588", - "sel_vertex": "#076678", - "origin_plane": "#fe80195e", - "origin_plane_edge": "#d65d0ec8", - "origin_plane_hover": "#45858896", - "origin_point": "#d5c4a1", - "axis_x": "#cc241d", - "axis_y": "#98971a", - "axis_z": "#458588", - "construction_plane": "#fe801946", - "construction_geom": "#9b3407", - "body": "#bdae93", - "body_edge": "#3c3836", - "manipulator": "#076678", - "preview_add": "#458588", - "preview_new": "#d79921", - "preview_sketch": "#98971a", - "preview_cut": "#cc241d6e", - "cube_face": "#fbf1c7", - "cube_border": "#7c6f64", - "cube_text": "#3c3836", - "cube_hover": "#83a598", - "viewport_top": "#f6edd0", - "viewport_bottom": "#ebdbb2", - "grid": "#d5c4a1", - "grid_major": "#bdae93", - "body_selected": "#83a598", - "sketch": "#076678", - "sketch_fixed": "#282828", - "sketch_halo": "#f6edd0", - "sketch_locked": "#79740e", - "dimension": "#1f5c40", - "dimension_driven": "#4f7d64", - "sketch_point": "#282828", - "sketch_point_free": "#076678", - "dialog_bg": "#f2e5bc", - "dialog_border": "#d5c4a1", - "chip": "#458588", - "section_cap": "#d65d0e", - "profile_fill": "#fabd2f46", - "glyph_bg": "#f8f2de", - "glyph_edge": "#a91714", - "glyph_text": "#a91714", - "overlay": "#f2e5bceb", - "rubber_band": "#076678", - "app_bar_text": "#fbf1c7", - "app_bar_hover": "#fbf1c71c", - "timeline_item": "#f2e5bc", - "timeline_item_error": "#eabe9c", - "timeline_marker": "#504945", - "field": "#f8f2de", - "box_window": "#076678", - "box_crossing": "#79740e", - "sketch_projected": "#8f3f71", - "sketch_lost": "#9d0006", - "sketch_construction": "#af3a03", - "sketch_centerline": "#d65d0e", - "bend_up": "#79740e", - "bend_down": "#af3a03", - "interference": "#cc241d", - "icons.agent": "#8f3f71", - "surface_fill": "#df663c", - "surface_soft": "#edd7ad", - "surface_ink": "#a83803", - "surface_accent": "#9b3407", - "mesh_fill": "#c08024", - "mesh_soft": "#eddcaf", - "mesh_ink": "#84560f", - "mesh_accent": "#7b5112", - "form_fill": "#c46fa2", - "form_soft": "#ead8b6", - "form_tab_ink": "#8f3f71", - "form_accent": "#893a6c", - "sheet_fill": "#659f7a", - "sheet_soft": "#e4ddb4", - "sheet_ink": "#38694b", - "render_fill": "#e85a4d", - "render_soft": "#ebd3ad", - "render_ink": "#9d0006", - "render_accent": "#a91714", - "sketch_soft": "#e8dcae", - "sketch_ink": "#66610c", - "form_ink": "#3c3836", - "form_dim": "#665c54", - "form_primary": "#076678", - "form_error": "#9d0006", - "form_error_fill": "#eabe9c", - "form_success": "#427b58", - "form_success_fill": "#dae3b6", - "dialog_body": "#f2e5bc", - "dialog_footer": "#ebdbb2", - "dialog_rule": "#d5c4a1", - "control_fill": "#f8f2de", - "control_border": "#a89984", - "dialog_label": "#3c3836", - "selected_ink": "#3c3836", - "selected_bg": "#c3ccae", - "on_accent": "#fbf1c7" - }, - "icon_palette": { - "ink": "#3c3836", - "line": "#504945", - "passive-1": "#e9e3df", - "passive-2": "#b8b3af", - "passive-3": "#8a8581", - "guide": "#7c6f64", - "solid": [ - "#87d5e9", - "#4e9caf", - "#076678" - ], - "surface": [ - "#feb299", - "#df663c", - "#9b3407" - ], - "mesh": [ - "#fcb864", - "#c08024", - "#7b5112" - ], - "form": [ - "#fbaad9", - "#c46fa2", - "#893a6c" - ], - "sketch": [ - "#d0cc73", - "#989439", - "#625e15" - ], - "constraint": [ - "#fab3a8", - "#e85a4d", - "#a91714" - ], - "construction": [ - "#feb299", - "#df663c", - "#9b3407" - ] - } -} diff --git a/assets/styles/ui/themes/gruvbox-light.json b/assets/styles/ui/themes/gruvbox-light.json index b61a4cd273..97d8056a8a 100644 --- a/assets/styles/ui/themes/gruvbox-light.json +++ b/assets/styles/ui/themes/gruvbox-light.json @@ -167,5 +167,127 @@ "#df663c", "#9b3407" ] - } + }, + "options": [ + { + "id": "contrast", + "type": "choice", + "default": "standard", + "label": { + "de": "Kontrast", + "en": "Contrast", + "ja": "コントラスト", + "ko": "대비", + "zh": "对比度" + }, + "help": { + "de": "Wähle die weiche, normale oder harte Gruvbox-Palette.", + "en": "Choose the soft, standard or hard Gruvbox palette.", + "ja": "Gruvboxのソフト・標準・ハードのパレットを選びます。", + "ko": "Gruvbox의 부드러운, 표준 또는 강한 팔레트를 선택합니다.", + "zh": "选择柔和、标准或强对比的 Gruvbox 配色。" + }, + "values": [ + { + "id": "soft", + "label": { + "de": "Weich", + "en": "Soft", + "ja": "ソフト", + "ko": "부드럽게", + "zh": "柔和" + } + }, + { + "id": "standard", + "label": { + "de": "Standard", + "en": "Standard", + "ja": "標準", + "ko": "표준", + "zh": "标准" + } + }, + { + "id": "hard", + "label": { + "de": "Hart", + "en": "Hard", + "ja": "ハード", + "ko": "강하게", + "zh": "强对比" + } + } + ] + } + ], + "overlays": [ + { + "when": { + "opt": "contrast", + "eq": "soft" + }, + "colors": { + "toolbar": "#f2e5bc", + "panel": "#f2e5bc", + "accent_soft": "#c3ccae", + "hover": "#eedfb6", + "viewport_top": "#f6edd0", + "sketch_halo": "#f6edd0", + "dialog_bg": "#f2e5bc", + "glyph_bg": "#f8f2de", + "overlay": "#f2e5bceb", + "timeline_item": "#f2e5bc", + "timeline_item_error": "#eabe9c", + "field": "#f8f2de", + "surface_soft": "#edd7ad", + "surface_ink": "#a83803", + "mesh_soft": "#eddcaf", + "mesh_ink": "#84560f", + "form_soft": "#ead8b6", + "sheet_soft": "#e4ddb4", + "sheet_ink": "#38694b", + "render_soft": "#ebd3ad", + "sketch_soft": "#e8dcae", + "sketch_ink": "#66610c", + "form_error_fill": "#eabe9c", + "dialog_body": "#f2e5bc", + "control_fill": "#f8f2de", + "selected_bg": "#c3ccae" + } + }, + { + "when": { + "opt": "contrast", + "eq": "hard" + }, + "colors": { + "toolbar": "#f9f5d7", + "panel": "#f9f5d7", + "accent_soft": "#c9d8c4", + "hover": "#f1e5c1", + "viewport_top": "#fbf8e3", + "sketch_halo": "#fbf8e3", + "dialog_bg": "#f9f5d7", + "glyph_bg": "#fcfaeb", + "overlay": "#f9f5d7eb", + "timeline_item": "#f9f5d7", + "timeline_item_error": "#f0cbb2", + "field": "#fcfaeb", + "surface_soft": "#f3e6c6", + "mesh_soft": "#f4ebc7", + "mesh_ink": "#915e10", + "form_soft": "#f1e6cf", + "sheet_soft": "#eaebcd", + "sheet_ink": "#3d7252", + "render_soft": "#f2e1c6", + "sketch_soft": "#efebc7", + "sketch_ink": "#6e6a0d", + "form_error_fill": "#f0cbb2", + "dialog_body": "#f9f5d7", + "control_fill": "#fcfaeb", + "selected_bg": "#c9d8c4" + } + } + ] } diff --git a/crates/doc/src/assembly.rs b/crates/doc/src/assembly.rs index a2473e03a5..4babdfcc13 100644 --- a/crates/doc/src/assembly.rs +++ b/crates/doc/src/assembly.rs @@ -456,7 +456,14 @@ impl Document { let id = self.next_id.max(1); self.next_id = id.checked_add(1).ok_or_else(|| DocError::Invalid("too many items in the design".into()))?; let cname = self.components.iter().find(|c| c.id == component).map(|c| c.name.clone()).unwrap_or_default(); - let k = self.occurrences.iter().filter(|o| o.component == component).count() + 1; + // The next number, or, when an occurrence already has that name (one before it was + // deleted, or of another component of that name), the first number none has (#9738). + let next = self.occurrences.iter().filter(|o| o.component == component).count() + 1; + let free = |k: &usize| { + let name = format!("{cname}:{k}"); + !self.occurrences.iter().any(|o| o.name == name) + }; + let k = std::iter::once(next).chain(1..).find(free).unwrap_or(next); self.occurrences.push(crate::Occurrence { id, component, parent, name: format!("{cname}:{k}"), transform, grounded: false }); Ok(id) } diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index cff8ab07ac..e41ceaeb17 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -1083,6 +1083,11 @@ pub enum FeatureKind { /// bodies follow. Replaces `to`. #[serde(default, skip_serializing_if = "Option::is_none")] to_plane: Option, + /// Point-to-point alignment: the directions the offset moves along (the chosen world + /// X/Y/Z axes in the frame of the bodies being moved). `None`: all three axes, the + /// offset in full. + #[serde(default, skip_serializing_if = "Option::is_none")] + axes: Option>, }, /// A helical coil: a circular or square section swept `turns` times about `axis`, rising /// `pitch` per turn, on a helix of `diameter` (the section's centre, or its inside or outside). @@ -2628,6 +2633,19 @@ pub struct DesignLink { /// local overrides and survive a source update. Older links start with no baseline. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub inherited: Vec, + /// SHA-256 of the design file's bytes as this design last read them ([`Self::file_digest`]): + /// opened again, this design tells which linked files changed meanwhile (#9739). Links made + /// before it was kept have none and are taken as up to date. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub file_sha256: Option, +} + +impl DesignLink { + /// What [`Self::file_sha256`] keeps for a design file's `bytes`. + pub fn file_digest(bytes: &[u8]) -> String { + use sha2::{Digest, Sha256}; + format!("{:x}", Sha256::digest(bytes)) + } } /// The values last inherited by a linked body, not another editable material library. @@ -3471,15 +3489,35 @@ impl Document { self.features.iter().find(|f| f.name.eq_ignore_ascii_case(r)) } + /// Resolve a textual timeline reference. An exact name wins even when it looks like an id. + /// Older scripts also pass ids as strings or use a different case for a unique name. + pub fn resolve_feature_name(&self, name: &str) -> Result<&Feature> { + if let Some(feature) = self.features.iter().find(|f| f.name == name) { + return Ok(feature); + } + if let Ok(id) = name.parse::() + && let Some(feature) = self.feature(id) + { + return Ok(feature); + } + let mut matches = self.features.iter().filter(|f| f.name.eq_ignore_ascii_case(name)); + let feature = matches.next().ok_or_else(|| DocError::Invalid(format!("no feature `{name}`")))?; + if matches.next().is_some() { + return Err(DocError::Invalid(format!("ambiguous feature `{name}`. Use its exact name or numeric id"))); + } + Ok(feature) + } + /// The feature a pattern or mirror seed names, resolved the way `replay()` resolved seed /// names before seeds were keyed by id (#1134): an exact name match on a feature that is /// not a sketch or a construction plane wins — a sketch and its extrude often share a - /// name, and the feature that makes geometry is the one to copy — else [`find_feature`]. + /// name, and the feature that makes geometry is the one to copy. Other references use + /// the timeline lookup. pub fn seed_feature(&self, name: &str) -> Option<&Feature> { self.features .iter() .find(|x| x.name == name && !matches!(x.kind, FeatureKind::Sketch { .. } | FeatureKind::ConstructionPlane { .. })) - .or_else(|| self.find_feature(name)) + .or_else(|| self.resolve_feature_name(name).ok()) } fn unique_name(&self, base: &str) -> String { @@ -4173,6 +4211,14 @@ impl Document { } else { solidcraft_sketch::solve_after_edit_with_analysis(sk, stored, was(sk).as_deref(), analysis) }; + // An attachment to the old offset can prevent the source from moving at all. + // Retry from a source-only seed, adopting it only if every constraint still holds. + if !report.ok() + && report.oversized.is_none() + && let Some(retried) = sk.solve_after_offset_edit(stored, was(sk).as_deref(), analysis) + { + report = retried; + } } Ok(report) } diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 62927a487f..fd095d70c9 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -1194,13 +1194,21 @@ impl ModelState { /// to the budget keep only its last few states, so setting a parameter back recomputed what /// had dropped out. fn added_bytes(&self, before: &ModelState) -> usize { + // `before`'s bodies by solid, and its sketches by address: looked up per body and sketch, + // not searched (every feature's result is charged, so searching was bodies² per feature). + // Bodies sharing a solid are still told apart by `same_shape` (the mesh as well). + let mut solids: std::collections::HashMap> = std::collections::HashMap::with_capacity(before.bodies.len()); + for o in &before.bodies { + solids.entry(o.body.solid_id()).or_default().push(&o.body); + } let bodies: usize = self .bodies .iter() - .filter(|b| !before.bodies.iter().any(|o| o.body.same_shape(&b.body))) + .filter(|b| !solids.get(&b.body.solid_id()).is_some_and(|os| os.iter().any(|o| o.same_shape(&b.body)))) .map(|b| if b.bytes > 0 { b.bytes } else { b.body.retained_bytes() }) .sum(); - let sketches: usize = self.sketches.iter().filter(|s| !before.sketches.iter().any(|o| Arc::ptr_eq(o, s))).map(|s| s.retained_bytes()).sum(); + let kept: std::collections::HashSet<*const SolvedSketch> = before.sketches.iter().map(Arc::as_ptr).collect(); + let sketches: usize = self.sketches.iter().filter(|s| !kept.contains(&Arc::as_ptr(s))).map(|s| s.retained_bytes()).sum(); bodies + sketches + self.sheets.len() * 4096 + self.threads.len() * 192 + self.construct.len() * 192 } @@ -1299,6 +1307,10 @@ pub struct Model { kept: KeptSlot, /// The imported texts' digests the last `evaluate` fingerprinted the features with. step_digests: StepDigests, + /// The address of the shared document whose fingerprints the results were last given in + /// full, with [`fingerprints_digest`] of them then ([`Model::pinned_prefix`]). Only an + /// address: a reference to the document would make every in-place edit of it a copy. + fingerprinted: Option<(usize, u64)>, /// The document context the last `evaluate` ran in ([`context_digest`]). context: u64, /// The last active result of each feature hidden by the history marker, with its document @@ -1418,32 +1430,40 @@ fn quantize(sk: &mut solidcraft_sketch::Sketch) { /// file's text by its digest. /// Hash [`feature_json`] of `f` into `h`. Every evaluation fingerprints every feature, and in a /// design built command by command almost all of them are the same as last time: the text of a -/// feature is kept (per thread, by id) with the feature it was written from, and written again -/// only when the feature differs. An Import's text also depends on its STEP digest, so it is -/// written each time as before. +/// feature is kept (by id) with the feature it was written from, and written again only when the +/// feature differs. An Import's text also depends on its STEP digest, so it is written each time +/// as before. +/// +/// The texts are shared by every thread: a command evaluated ahead runs on a new worker thread +/// each time (the desktop's deferred commands and delete checks, the control channel's ahead +/// jobs), and a per-thread store started empty there, so every ahead run wrote out every +/// feature again. The lock is held only to look up or store an entry, never while a feature is +/// compared or written; an entry is the same whichever thread wrote it, since the text is a +/// function of the feature alone. fn hash_feature_json(f: &Feature, steps: &StepDigests, h: &mut impl Hasher) { + type Written = std::collections::HashMap>; + static WRITTEN: std::sync::LazyLock> = std::sync::LazyLock::new(Default::default); if matches!(f.kind, FeatureKind::Import { .. }) { feature_json(f, steps).hash(h); return; } - thread_local! { - static WRITTEN: std::cell::RefCell> = std::cell::RefCell::new(std::collections::HashMap::new()); + let seen = WRITTEN.lock().unwrap_or_else(|e| e.into_inner()).get(&f.id).cloned(); + if let Some(seen) = seen + && seen.0 == *f + { + seen.1.hash(h); + return; } - WRITTEN.with(|written| { - let mut written = written.borrow_mut(); - if let Some((seen, json)) = written.get(&f.id) - && seen == f - { - json.hash(h); - return; - } - let json = feature_json(f, steps); - json.hash(h); - if written.len() >= 1 << 15 { - written.clear(); - } - written.insert(f.id, (f.clone(), json)); - }); + #[cfg(test)] + cost_tests::count_written(); + let json = feature_json(f, steps); + json.hash(h); + let entry = Arc::new((f.clone(), json)); + let mut written = WRITTEN.lock().unwrap_or_else(|e| e.into_inner()); + if written.len() >= 1 << 15 { + written.clear(); + } + written.insert(f.id, entry); } fn feature_json(f: &Feature, steps: &StepDigests) -> String { @@ -2131,6 +2151,7 @@ impl Model { /// Re-evaluate the document, reusing results up to the first changed feature. pub fn evaluate(&mut self, doc: &Document) { self.placement_inputs = None; + self.fingerprinted = None; self.placement_local = true; let (vals, perr) = evaluation_values(doc); self.param_errors = perr; @@ -2532,19 +2553,76 @@ impl Model { /// Set every result's fingerprint to `doc`'s, so the next evaluation reuses every result /// (after a change that builds nothing differently). pub fn refingerprint(&mut self, doc: &Document) { + self.refingerprint_from(doc, 0); + } + + /// How many leading results keep their fingerprints when `doc` has the body names of the + /// features `renamed` pinned, and nothing else changed (see [`Model::refingerprint_shared`]). + /// Every command pins, and fingerprinting the whole timeline again for it cost as much as + /// the fingerprinting of the evaluation itself. When the results hold `doc`'s fingerprints + /// (the address and digest kept when they were given prove it), those before the first + /// renamed feature stand, since nothing they read changes, unless a pattern or mirror of + /// features among them replays a renamed one (it fingerprints its sources' definitions + /// wherever they are). Without that proof: none. + pub fn pinned_prefix(&self, doc: &Arc, renamed: &[u64]) -> usize { + let proven = self.fingerprinted.is_some_and(|(of, digest)| of == Arc::as_ptr(doc) as usize && digest == fingerprints_digest(&self.results)); + if !proven { + return 0; + } + let Some(first) = doc.features.iter().position(|f| renamed.contains(&f.id)) else { return doc.features.len() }; + let replays_renamed = |f: &Feature| match &f.kind { + FeatureKind::Pattern { features, bodies, .. } | FeatureKind::Mirror { features, bodies, .. } => { + bodies.is_empty() && !features.is_empty() && replayed_sources(doc, features).iter().any(|g| renamed.contains(&g.id)) + } + _ => false, + }; + doc.features.iter().take(first).position(replays_renamed).unwrap_or(first) + } + + /// Drop the proof [`Model::pinned_prefix`] reads: the document at that address is about to + /// change in place. + pub fn forget_fingerprints(&mut self) { + self.fingerprinted = None; + } + + /// Fingerprint the results from `start` on for `doc`, chained onto the fingerprint before. + pub(crate) fn refingerprint_from(&mut self, doc: &Document, start: usize) { self.placement_inputs = None; + self.fingerprinted = None; + self.step_digests = StepDigests::of(doc, &self.step_digests); + let start = start.min(self.results.len()).min(doc.features.len()); + if start == self.results.len().min(doc.features.len()) { + return; + } let (vals, _) = evaluation_values(doc); let marker = doc.marker.unwrap_or(usize::MAX); - self.step_digests = StepDigests::of(doc, &self.step_digests); - let mut prev = 0; + let last = start.checked_sub(1).and_then(|i| self.results.get(i)); + let mut prev = last.map_or(0, |r| r.fingerprint); // Each feature's fingerprint reads the model it starts from, as evaluating does. - let mut before = Arc::new(ModelState::default()); - for (i, (r, f)) in self.results.iter_mut().zip(&doc.features).enumerate() { + let mut before = last.map_or_else(|| Arc::new(ModelState::default()), |r| r.state.clone()); + for (i, (r, f)) in self.results.iter_mut().zip(&doc.features).enumerate().skip(start) { prev = chain_fingerprint(doc, &vals, f, prev, i >= marker, &self.step_digests, Some(&before)); r.fingerprint = prev; before = r.state.clone(); } } + + /// Note that the results now hold `doc`'s fingerprints (for [`Model::pinned_prefix`]). + pub(crate) fn mark_fingerprinted(&mut self, doc: &Arc) { + self.fingerprinted = Some((Arc::as_ptr(doc) as usize, fingerprints_digest(&self.results))); + } +} + +/// A digest of the results' features and fingerprints, which proves they are still the ones +/// [`Model::mark_fingerprinted`] saw (whatever changed them since). +fn fingerprints_digest(results: &[FeatureResult]) -> u64 { + let mut h = std::collections::hash_map::DefaultHasher::new(); + results.len().hash(&mut h); + for r in results { + r.id.hash(&mut h); + r.fingerprint.hash(&mut h); + } + h.finish() } /// Key of the design's length unit (mm per unit) among the values (no parameter can be named @@ -3238,6 +3316,11 @@ fn apply_traced_with( // Not yet: a cut that splits one piece of such a body still splits all of them; keeping // the untouched ones together needs each new piece traced to the piece it came from. let mut taken: std::collections::BTreeSet = state.bodies.iter().map(|b| b.name.clone()).collect(); + // The pieces past the first belong to this feature, like a split's pieces (#10123): + // `body.rename` stores their names in this feature's `body_names`, in this order, so a + // renamed piece keeps its own name through a rebuild instead of landing on a sibling + // of the body it was cut from. + let mut extra = 0usize; let mut split: Vec = Vec::new(); for mb in std::mem::take(&mut state.bodies) { let Some(&had) = affected.get(&mb.name).filter(|_| !state.sheets.iter().any(|s| s.body == mb.name)) else { @@ -3253,13 +3336,18 @@ fn apply_traced_with( // (so pieces of one face in different bodies are told apart). let lineages = crate::lineage::Lineage::lumps(&mb.body, mb.lineage().as_deref(), &pieces); for (k, (p, lineage)) in pieces.into_iter().zip(lineages).enumerate() { - let name = if k == 0 { - mb.name.clone() + let (name, made_by) = if k == 0 { + (mb.name.clone(), mb.feature) } else { - (k..=k + taken.len()).map(|j| format!("{} ({j})", mb.name)).find(|n| !taken.contains(n)).unwrap_or_default() + let stored = f.body_names.get(extra).filter(|n| !n.trim().is_empty() && !taken.contains(*n)).cloned(); + extra += 1; + let name = stored.unwrap_or_else(|| { + (k..=k + taken.len()).map(|j| format!("{} ({j})", mb.name)).find(|n| !taken.contains(n)).unwrap_or_default() + }); + (name, f.id) }; taken.insert(name.clone()); - split.push(ModelBody::new(name, p, mb.feature).with_lineage(lineage)); + split.push(ModelBody::new(name, p, made_by).with_lineage(lineage)); } } state.bodies = split; diff --git a/crates/doc/src/eval_cost_tests.rs b/crates/doc/src/eval_cost_tests.rs index 8139c60a21..360eeaeb3a 100644 --- a/crates/doc/src/eval_cost_tests.rs +++ b/crates/doc/src/eval_cost_tests.rs @@ -36,6 +36,22 @@ pub(super) fn count(f: impl FnOnce(&mut Counts)) { }); } +thread_local! { + static WRITTEN: Cell = const { Cell::new(0) }; +} + +/// A feature written out on this thread to be fingerprinted. +pub(super) fn count_written() { + WRITTEN.with(|c| c.set(c.get() + 1)); +} + +/// The features `f` writes out on this thread to fingerprint them. +fn written(f: impl FnOnce()) -> usize { + WRITTEN.with(|c| c.set(0)); + f(); + WRITTEN.with(Cell::get) +} + /// The work `f` does on this thread. fn counted(f: impl FnOnce()) -> Counts { COUNTS.with(|c| c.set(Counts::default())); @@ -358,6 +374,132 @@ fn an_imported_file_is_fingerprinted_by_its_text() { assert_eq!(model.state().bodies.len(), 3); } +/// Pinning body names fingerprints the timeline again only from the first renamed feature on +/// (`Model::pinned_prefix`), and gives the fingerprints the full pass gives: random pins +/// on a timeline of boxes, cuts, patterns of features (one placed before its source, as one made +/// with the timeline rolled back is), a suppressed feature and a history marker, after an +/// evaluation and after earlier pins. +#[test] +fn pinning_body_names_fingerprints_as_the_full_pass_does() { + let kind = |v: serde_json::Value| serde_json::from_value::(v).unwrap(); + let mut doc = Document::new("pins"); + let mut ids = Vec::new(); + for i in 0..10 { + let x = 20.0 * i as f64; + let b = + kind(serde_json::json!({"type": "box", "corner": [x, 0.0, 0.0], "length": "10", "width": "10", "height": "10", "operation": "new_body"})); + ids.push(doc.add_feature(b, None).unwrap()); + if i % 3 == 1 { + let c = kind( + serde_json::json!({"type": "cylinder", "axis": [0.0, 0.0, 1.0], "base": [x + 5.0, 5.0, -1.0], "height": "12", "radius": "2", "operation": "cut"}), + ); + ids.push(doc.add_feature(c, None).unwrap()); + } + } + let circular = |features: Vec| { + kind(serde_json::json!({"type": "pattern", "features": features, "bodies": [], + "pattern": {"type": "circular", "angle": "360 deg", "axis": [0.0, 0.0, 1.0], "count": "3", "origin": [0.0, -40.0, 0.0]}})) + }; + ids.push(doc.add_feature(circular(vec![ids[2]]), None).unwrap()); + // A pattern of a later feature, placed early in the timeline. + let early = doc.add_feature(circular(vec![ids[9]]), None).unwrap(); + let at = doc.features.iter().position(|f| f.id == early).unwrap(); + let f = doc.features.remove(at); + doc.features.insert(3, f); + ids.push(early); + doc.feature_mut(ids[5]).unwrap().suppressed = true; + + let mut seed = 0x2545_f491_4f6c_dd1du64; + let mut next = |n: usize| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + (seed % n as u64) as usize + }; + let fps = |m: &Model| m.results.iter().map(|r| (r.id, r.fingerprint)).collect::>(); + let mut old = Arc::new(doc); + let mut model = Model::new(); + model.evaluate_shared(&old); + let (mut proven, mut kept) = (0, 0); + for round in 0..60 { + if round % 20 == 10 { + // From an evaluation's fingerprints again, with the marker in some rounds. + let mut d = (*old).clone(); + d.marker = (round == 30).then_some(d.features.len() - 2); + old = Arc::new(d); + model.evaluate_shared(&old); + } + let mut new = (*old).clone(); + let mut renamed = Vec::new(); + for _ in 0..1 + next(3) { + let f = &mut new.features[next(old.features.len())]; + let slot = next(3); + f.body_names.resize(f.body_names.len().max(slot + 1), String::new()); + f.body_names[slot] = format!("Pinned {round} {slot}"); + renamed.push(f.id); + } + new.reserved_body_names.insert(format!("Body{}", 100 + round)); + let new = Arc::new(new); + let start = model.pinned_prefix(&old, &renamed); + let first = new.features.iter().position(|f| renamed.contains(&f.id)).unwrap(); + assert!(start <= first, "round {round}: kept {start} past the first renamed feature {first}"); + proven += usize::from(start > 0); + kept += start; + let mut partial = model.clone(); + partial.refingerprint_shared(&new, start); + let mut full = model.clone(); + full.refingerprint_shared(&new, 0); + assert_eq!(fps(&partial), fps(&full), "round {round}"); + assert_ne!(fps(&partial), fps(&model), "round {round}: the names are fingerprinted"); + // The pinned fingerprints prove themselves for the next pin, as the full pass's do. + assert_eq!(partial.fingerprinted.as_ref().map(|(_, d)| *d), full.fingerprinted.as_ref().map(|(_, d)| *d)); + model = partial; + old = new; + } + assert!(proven > 40 && kept > 0, "the stored fingerprints were reused: {proven} rounds, {kept} results"); + + // No proof, no reuse: results changed since, another design, or the design about to change + // in place. + let last = [*ids.last().unwrap()]; + assert!(model.pinned_prefix(&old, &last) > 0); + let mut changed = model.clone(); + changed.results[0].fingerprint ^= 1; + assert_eq!(changed.pinned_prefix(&old, &last), 0); + assert_eq!(model.pinned_prefix(&Arc::new((*old).clone()), &last), 0); + let mut forgot = model.clone(); + forgot.forget_fingerprints(); + assert_eq!(forgot.pinned_prefix(&old, &last), 0); +} + +/// A design evaluated ahead, on a worker thread of its own, fingerprints its features from the +/// texts the window's evaluation wrote: none is written out again there (the store was per +/// thread, and every ahead run started it empty), and the fingerprints are the same. +#[test] +fn an_evaluation_on_another_thread_reuses_the_written_features() { + let mut doc = Document::new("ahead"); + // Ids no other test's design uses: the store is shared by the tests running beside this one. + doc.next_id = 0x0007_e57a_4ead_0000; + for i in 0..40 { + let x = 20.0 * i as f64; + let b = serde_json::json!({"type": "box", "corner": [x, 0.0, 0.0], "length": "10", "width": "10", "height": "10", "operation": "new_body"}); + doc.add_feature(serde_json::from_value(b).unwrap(), None).unwrap(); + } + let mut window = Model::new(); + let first = written(|| window.evaluate(&doc)); + assert!(first >= doc.features.len(), "{first} written"); + let fps = |m: &Model| m.results.iter().map(|r| r.fingerprint).collect::>(); + let (doc2, mut ahead) = (doc.clone(), window.clone()); + ahead.no_cache = true; + let (work, ahead_fps) = std::thread::spawn(move || { + let work = written(|| ahead.evaluate(&doc2)); + (work, fps(&ahead)) + }) + .join() + .unwrap(); + assert_eq!(work, 0, "features written out again"); + assert_eq!(ahead_fps, fps(&window)); +} + /// A 10 mm square plate per hole along X, 4 mm thick, in component `comp`, with `holes` square /// holes cut through it by boxes drawn in component `tools` (the plate's own, usually). The /// plate's body name. diff --git a/crates/doc/src/eval_more.rs b/crates/doc/src/eval_more.rs index 2e94d4a6e3..7a5b7c0f7a 100644 --- a/crates/doc/src/eval_more.rs +++ b/crates/doc/src/eval_more.rs @@ -97,7 +97,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap, f: &Feature, } Ok(()) } - FeatureKind::Align { bodies, from, to, from_normal, to_normal, flip, from_plane, to_plane } => { + FeatureKind::Align { bodies, from, to, from_normal, to_normal, flip, from_plane, to_plane, axes } => { // A plane on either side is worked out now, so the bodies follow it when it moves. let (mut from, mut from_normal) = (from.0, from_normal.and_then(|n| n.normalized())); if let Some(p) = from_plane { @@ -126,6 +126,28 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap, f: &Feature, (Some(_), Some(n)) => n * (to - from).dot(n), _ => to - from, }; + // Point-to-point alignment can keep only the chosen world X/Y/Z directions, + // stored in the bodies' frame. A missing list means all three (the full offset). + let d = match axes { + None => d, + Some(dirs) if dirs.is_empty() => { + return Err(DocError::Invalid("the align axes enable none of X, Y and Z".into())); + } + Some(dirs) => { + let mut kept = Vec3::ZERO; + let mut used = false; + for a in dirs { + if let Some(a) = a.0.normalized() { + kept += a * d.dot(a); + used = true; + } + } + if !used { + return Err(DocError::Invalid("the align axes have no direction".into())); + } + kept + } + }; m[3][0] += d.x; m[3][1] += d.y; m[3][2] += d.z; @@ -821,12 +843,12 @@ pub(super) fn sheet_eval( // (its outer level, `hi`) with z into the material, as with bend edges, so the sheet // takes the plate's place: its thickness runs from `hi` back to `lo`. let plane = Plane::from_normal(face.0 + n * hi, n * -1.0).ok_or_else(|| DocError::Invalid("face".into()))?; + let slice = FaceSlice { mesh: &mesh, plane, at: face.0, n, tol }; // Recesses in one face (an engraved label) become pockets; the outline is the other face's. - let recesses = plate_recesses(&mb.body, &mesh, &plane, face.0, n, (lo, hi), tol)?; + let recesses = slice.recesses(&mb.body, (lo, hi))?; let mut walls = std::collections::HashMap::new(); let mut wall = |f: u32| *walls.entry(f).or_insert_with(|| sheet::round_wall(&mb.body, &plane, n, f)); - let mut loops: Vec = - face_outline(&mesh, &plane, face.0, n, recesses.level, tol).iter().map(|l| sheet::outline_loop(l, &mut wall, tol * 1e-2)).collect(); + let mut loops: Vec = slice.outline(recesses.level).iter().map(|l| sheet::outline_loop(l, &mut wall, tol * 1e-2)).collect(); loops.sort_by(|a, b| b.signed_area().abs().total_cmp(&a.signed_area().abs())); let mut it = loops.into_iter(); let outer = it.next().ok_or_else(|| DocError::Invalid("no outline on that face".into()))?.ccw(); @@ -1584,43 +1606,58 @@ fn parted_note(sh: &sheet::SheetBody, material: &[Region2], before: &sheet::Shee }) } -/// Outline loops (in `plane` coordinates) of the mesh triangles lying in the plane at distance -/// `level` from `at` along `n`: the edges used by one of them only, chained. -fn face_loops(mesh: &solidcraft_geom::Mesh, plane: &Plane, at: Vec3, n: Vec3, level: f64, tol: f64) -> Vec> { - face_outline(mesh, plane, at, n, level, tol).into_iter().map(|l| l.into_iter().map(|(p, _)| p).collect()).collect() +/// The face that [`FaceSlice::loops`], [`FaceSlice::outline`] and [`FaceSlice::recesses`] measure: +/// the mesh, the plane the outline's coordinates live in, the point `at` and normal `n` that levels +/// are measured from, and the tolerance. Callers build it once and pass it on unchanged. +/// (`n` stays a `Vec3` until #2675 lands `UnitVec3` upstream.) +struct FaceSlice<'a> { + mesh: &'a Mesh, + plane: Plane, + at: Vec3, + n: Vec3, + tol: f64, } -/// [`face_loops`] with each point's B-rep face across the outline from it to the next point (the -/// wall the outline runs along there): the face off the plane that meets both points. -fn face_outline(mesh: &solidcraft_geom::Mesh, plane: &Plane, at: Vec3, n: Vec3, level: f64, tol: f64) -> Vec)>> { - let on_plane = |t: &[u32; 3]| t.iter().all(|k| mesh.positions.get(*k as usize).is_some_and(|p| ((*p - at).dot(n) - level).abs() < tol)); - let on: Vec<[u32; 3]> = mesh.triangles.iter().copied().filter(on_plane).collect(); - let pos = |k: u32| mesh.positions.get(k as usize).copied().unwrap_or_default(); - let q = |p: Vec3| ((p.x * 1e6).round() as i64, (p.y * 1e6).round() as i64, (p.z * 1e6).round() as i64); - // The faces off the plane at each point on it, by position to a millionth of a millimetre - // either way (a neighbouring face's mesh may round a shared point differently). - let mut touch: std::collections::HashMap<(i64, i64, i64), Vec> = Default::default(); - for (t, f) in mesh.triangles.iter().zip(&mesh.tri_face).filter(|(t, _)| !on_plane(t)) { - for p in t.iter().map(|k| pos(*k)).filter(|p| ((*p - at).dot(n) - level).abs() < tol) { - let faces = touch.entry(q(p)).or_default(); - if !faces.contains(f) { - faces.push(*f); +impl FaceSlice<'_> { + /// Outline loops (in `plane` coordinates) of the mesh triangles lying in the plane at distance + /// `level` from `at` along `n`: the edges used by one of them only, chained. + fn loops(&self, level: f64) -> Vec> { + self.outline(level).into_iter().map(|l| l.into_iter().map(|(p, _)| p).collect()).collect() + } + + /// [`FaceSlice::loops`] with each point's B-rep face across the outline from it to the next + /// point (the wall the outline runs along there): the face off the plane that meets both points. + fn outline(&self, level: f64) -> Vec)>> { + let (mesh, plane, at, n, tol) = (self.mesh, &self.plane, self.at, self.n, self.tol); + let on_plane = |t: &[u32; 3]| t.iter().all(|k| mesh.positions.get(*k as usize).is_some_and(|p| ((*p - at).dot(n) - level).abs() < tol)); + let on: Vec<[u32; 3]> = mesh.triangles.iter().copied().filter(on_plane).collect(); + let pos = |k: u32| mesh.positions.get(k as usize).copied().unwrap_or_default(); + let q = |p: Vec3| ((p.x * 1e6).round() as i64, (p.y * 1e6).round() as i64, (p.z * 1e6).round() as i64); + // The faces off the plane at each point on it, by position to a millionth of a millimetre + // either way (a neighbouring face's mesh may round a shared point differently). + let mut touch: std::collections::HashMap<(i64, i64, i64), Vec> = Default::default(); + for (t, f) in mesh.triangles.iter().zip(&mesh.tri_face).filter(|(t, _)| !on_plane(t)) { + for p in t.iter().map(|k| pos(*k)).filter(|p| ((*p - at).dot(n) - level).abs() < tol) { + let faces = touch.entry(q(p)).or_default(); + if !faces.contains(f) { + faces.push(*f); + } } } + let near = |p: Vec3| { + let (x, y, z) = q(p); + let cells = (-1..=1).flat_map(|i| (-1..=1).flat_map(move |j| (-1..=1).map(move |k| (x + i, y + j, z + k)))); + let mut faces: Vec = cells.filter_map(|c| touch.get(&c)).flatten().copied().collect(); + faces.sort_unstable(); + faces.dedup(); + faces + }; + let across = |a: Vec3, b: Vec3| { + let at_b = near(b); + near(a).into_iter().find(|f| at_b.contains(f)) + }; + chain_loops(mesh, plane, &on, across) } - let near = |p: Vec3| { - let (x, y, z) = q(p); - let cells = (-1..=1).flat_map(|i| (-1..=1).flat_map(move |j| (-1..=1).map(move |k| (x + i, y + j, z + k)))); - let mut faces: Vec = cells.filter_map(|c| touch.get(&c)).flatten().copied().collect(); - faces.sort_unstable(); - faces.dedup(); - faces - }; - let across = |a: Vec3, b: Vec3| { - let at_b = near(b); - near(a).into_iter().find(|f| at_b.contains(f)) - }; - chain_loops(mesh, plane, &on, across) } /// The loops (in `plane` coordinates) that bound the triangles `on` of the mesh. @@ -1710,138 +1747,142 @@ struct LevelFace { tris: Vec<(Vec3, f64)>, } -/// The recesses of a plate lying between levels `lo` and `hi` along `n` (measured from `at`), -/// whose sheet is built in `plane`. A recess's floor is a face parallel to the plate inside its -/// thickness, lying in an opening of the face it is cut into, and its walls are square to the -/// plate (walls that are not show as material the plate's volume doesn't account for). A body -/// that is no plate once its recesses are filled has none, so the plate check refuses it as -/// before: a bent part, whose legs' faces lie inside its extent. Recesses are taken in one face -/// only, up to half the thickness deep, and no larger than the face they are cut into (a plate -/// raised in places is no plate of the raised thickness). -fn plate_recesses(body: &Body, mesh: &Mesh, plane: &Plane, at: Vec3, n: Vec3, (lo, hi): (f64, f64), tol: f64) -> Result { - let t = hi - lo; - let eps = t * 1e-3; - let level = |p: Vec3| (p - at).dot(n); - let mut faces: BTreeMap> = BTreeMap::new(); - for (tri, f) in mesh.triangles.iter().zip(&mesh.tri_face) { - let Some([a, b, c]) = mesh.tri(tri) else { continue }; - let cr = (b - a).cross(c - a); - let len = cr.len(); - if !(len > 1e-12) { - continue; - } - let up = cr.dot(n) > 0.0; - let entry = faces.entry(*f).or_insert(Some(LevelFace { lo: f64::MAX, hi: f64::MIN, up, area: 0.0, at: a, tris: Vec::new() })); - match entry { - Some(face) if face.up == up && cr.dot(n).abs() >= len * (1.0 - 1e-6) => { - for p in [a, b, c] { - face.lo = face.lo.min(level(p)); - face.hi = face.hi.max(level(p)); - } - face.area += len / 2.0; - face.tris.push(((a + b + c) / 3.0, len / 2.0)); +impl FaceSlice<'_> { + /// The recesses of a plate lying between levels `lo` and `hi` along `n` (measured from `at`), + /// whose sheet is built in `plane`. A recess's floor is a face parallel to the plate inside its + /// thickness, lying in an opening of the face it is cut into, and its walls are square to the + /// plate (walls that are not show as material the plate's volume doesn't account for). A body + /// that is no plate once its recesses are filled has none, so the plate check refuses it as + /// before: a bent part, whose legs' faces lie inside its extent. Recesses are taken in one face + /// only, up to half the thickness deep, and no larger than the face they are cut into (a plate + /// raised in places is no plate of the raised thickness). + fn recesses(&self, body: &Body, (lo, hi): (f64, f64)) -> Result { + let (mesh, plane, at, n, tol) = (self.mesh, &self.plane, self.at, self.n, self.tol); + let t = hi - lo; + let eps = t * 1e-3; + let level = |p: Vec3| (p - at).dot(n); + let mut faces: BTreeMap> = BTreeMap::new(); + for (tri, f) in mesh.triangles.iter().zip(&mesh.tri_face) { + let Some([a, b, c]) = mesh.tri(tri) else { continue }; + let cr = (b - a).cross(c - a); + let len = cr.len(); + if !(len > 1e-12) { + continue; + } + let up = cr.dot(n) > 0.0; + let entry = faces.entry(*f).or_insert(Some(LevelFace { lo: f64::MAX, hi: f64::MIN, up, area: 0.0, at: a, tris: Vec::new() })); + match entry { + Some(face) if face.up == up && cr.dot(n).abs() >= len * (1.0 - 1e-6) => { + for p in [a, b, c] { + face.lo = face.lo.min(level(p)); + face.hi = face.hi.max(level(p)); + } + face.area += len / 2.0; + face.tris.push(((a + b + c) / 3.0, len / 2.0)); + } + _ => *entry = None, } - _ => *entry = None, } - } - // The floors (face index, level, the face), and the areas of the plate's two faces (at `lo` - // and at `hi`). - let mut floors: Vec<(usize, f64, &LevelFace)> = Vec::new(); - let (mut bottom, mut top) = (0.0, 0.0); - for (f, face) in faces.iter().filter_map(|(f, face)| Some((*f as usize, face.as_ref()?))) { - let d = (face.lo + face.hi) / 2.0; - if face.hi - face.lo > eps { - continue; - } else if (d - hi).abs() <= eps && face.up { - top += face.area; - } else if (d - lo).abs() <= eps && !face.up { - bottom += face.area; - } else if d > lo + eps && d < hi - eps { - floors.push((f, d, face)); + // The floors (face index, level, the face), and the areas of the plate's two faces (at `lo` + // and at `hi`). + let mut floors: Vec<(usize, f64, &LevelFace)> = Vec::new(); + let (mut bottom, mut top) = (0.0, 0.0); + for (f, face) in faces.iter().filter_map(|(f, face)| Some((*f as usize, face.as_ref()?))) { + let d = (face.lo + face.hi) / 2.0; + if face.hi - face.lo > eps { + continue; + } else if (d - hi).abs() <= eps && face.up { + top += face.area; + } else if (d - lo).abs() <= eps && !face.up { + bottom += face.area; + } else if d > lo + eps && d < hi - eps { + floors.push((f, d, face)); + } } - } - let none = || Recesses { level: lo, pockets: Vec::new(), volume: 0.0 }; - if floors.is_empty() { - return Ok(none()); - } - // A floor is a recess's where (most of) it lies in an opening of the face it is cut into: - // inside that face's outline and in a hole of it, not on an island in the hole. (Its - // triangles are those at its level within `eps`: `tol` may exceed a recess's depth.) - let loops_at = |level: f64| -> Vec { face_loops(mesh, plane, at, n, level, eps).into_iter().map(|l| Loop2::polygon(&l)).collect() }; - let (top_loops, bottom_loops) = (loops_at(hi), loops_at(lo)); - floors.retain(|(_, _, face)| { - let loops = if face.up { &top_loops } else { &bottom_loops }; - let open = |p: Vec2| { - let k = loops.iter().filter(|l| l.contains(p)).count(); - k >= 2 && k.is_multiple_of(2) - }; - face.tris.iter().filter(|(c, _)| open(plane.to_local(*c))).map(|(_, a)| a).sum::() > face.area / 2.0 - }); - let Some(&(_, _, first)) = floors.first() else { return Ok(none()) }; - // Filled, the recesses leave a plate: its outline as large at `lo` as at `hi` once their - // openings count, and its volume with theirs the outline times the thickness. Else (a bent - // part) they are none. - let depth_in = |d: f64, up: bool| if up { hi - d } else { d - lo }; - let opened = |up: bool| floors.iter().filter(|f| f.2.up == up).map(|f| f.2.area).sum::(); - let outline = bottom + opened(false); - let removed: f64 = floors.iter().map(|f| f.2.area * depth_in(f.1, f.2.up)).sum(); - let volume = kernel::measure(body)?.volume; - if (outline - top - opened(true)).abs() > 1e-3 * outline || (outline * t - volume - removed).abs() > 1e-3 * volume.max(1.0) { - return Ok(none()); - } - let pt = |p: Vec3| format!("({:.3}, {:.3}, {:.3})", p.x, p.y, p.z); - let up = first.up; - if let Some(other) = floors.iter().find(|f| f.2.up != up) { - return Err(DocError::Invalid(format!( - "not supported yet: converting a plate with recesses in both faces (at {} and {}); recesses in one face become pockets", - pt(first.at), - pt(other.2.at) - ))); - } - let depth_of = |d: f64| depth_in(d, up); - if let Some(deep) = floors.iter().find(|f| depth_of(f.1) > t / 2.0 + eps) { - return Err(DocError::Invalid(format!( - "not supported yet: the recess at {} is {:.3} mm deep, more than half the plate's {:.3} mm; shallower recesses (an engraving, a label) become pockets", - pt(deep.2.at), - depth_of(deep.1), - t - ))); - } - let face_area = if up { top } else { bottom }; - if floors.iter().map(|f| f.2.area).sum::() > face_area { - return Err(DocError::Invalid(format!( - "not supported yet: converting a plate whose recesses are larger than the face they are in (a plate thinner than {t:.3} mm, raised in places)" - ))); - } - // The floors' outlines, from a mesh fine enough to mark them by. - let fine = body.tessellate((t * 1e-2).min(tol).max(1e-4))?; - floors.sort_by(|a, b| depth_of(a.1).total_cmp(&depth_of(b.1))); - // Floors at one depth are one pocket where they meet. - let mut groups: Vec<(f64, Vec)> = Vec::new(); - for (f, d, ..) in &floors { - let on: Vec<[u32; 3]> = fine.triangles.iter().zip(&fine.tri_face).filter(|(_, g)| **g as usize == *f).map(|(tri, _)| *tri).collect(); - let mut loops = boundary_loops(&fine, plane, &on); - loops.sort_by(|a, b| b.signed_area().abs().total_cmp(&a.signed_area().abs())); - let mut it = loops.into_iter(); - let Some(outline) = it.next() else { continue }; - let region = Region2 { outer: Loop2::polygon(&outline).ccw(), holes: it.map(|l| Loop2::polygon(&l).ccw().reversed()).collect() }; - match groups.last_mut() { - Some((depth, regions)) if depth_of(*d) - *depth <= eps => regions.push(region), - _ => groups.push((depth_of(*d), vec![region])), + let none = || Recesses { level: lo, pockets: Vec::new(), volume: 0.0 }; + if floors.is_empty() { + return Ok(none()); } - } - // The sheet's z starts from the picked face when its plane faces away from that face. - let from_zero = up == (plane.normal().dot(n) < 0.0); - let mut work = sheet::FlatWork::new(); - let (mut pockets, mut volume) = (Vec::new(), 0.0); - for (depth, regions) in groups { - let (z0, z1) = if from_zero { (0.0, depth) } else { (t - depth, t) }; - for region in sheet::material_union(regions, &mut work)? { - volume += region.area().abs() * depth; - pockets.push((region, z0, z1)); + // A floor is a recess's where (most of) it lies in an opening of the face it is cut into: + // inside that face's outline and in a hole of it, not on an island in the hole. (Its + // triangles are those at its level within `eps`: `tol` may exceed a recess's depth.) + let probe = FaceSlice { mesh, plane: self.plane, at, n, tol: eps }; + let loops_at = |level: f64| -> Vec { probe.loops(level).into_iter().map(|l| Loop2::polygon(&l)).collect() }; + let (top_loops, bottom_loops) = (loops_at(hi), loops_at(lo)); + floors.retain(|(_, _, face)| { + let loops = if face.up { &top_loops } else { &bottom_loops }; + let open = |p: Vec2| { + let k = loops.iter().filter(|l| l.contains(p)).count(); + k >= 2 && k.is_multiple_of(2) + }; + face.tris.iter().filter(|(c, _)| open(plane.to_local(*c))).map(|(_, a)| a).sum::() > face.area / 2.0 + }); + let Some(&(_, _, first)) = floors.first() else { return Ok(none()) }; + // Filled, the recesses leave a plate: its outline as large at `lo` as at `hi` once their + // openings count, and its volume with theirs the outline times the thickness. Else (a bent + // part) they are none. + let depth_in = |d: f64, up: bool| if up { hi - d } else { d - lo }; + let opened = |up: bool| floors.iter().filter(|f| f.2.up == up).map(|f| f.2.area).sum::(); + let outline = bottom + opened(false); + let removed: f64 = floors.iter().map(|f| f.2.area * depth_in(f.1, f.2.up)).sum(); + let volume = kernel::measure(body)?.volume; + if (outline - top - opened(true)).abs() > 1e-3 * outline || (outline * t - volume - removed).abs() > 1e-3 * volume.max(1.0) { + return Ok(none()); } + let pt = |p: Vec3| format!("({:.3}, {:.3}, {:.3})", p.x, p.y, p.z); + let up = first.up; + if let Some(other) = floors.iter().find(|f| f.2.up != up) { + return Err(DocError::Invalid(format!( + "not supported yet: converting a plate with recesses in both faces (at {} and {}); recesses in one face become pockets", + pt(first.at), + pt(other.2.at) + ))); + } + let depth_of = |d: f64| depth_in(d, up); + if let Some(deep) = floors.iter().find(|f| depth_of(f.1) > t / 2.0 + eps) { + return Err(DocError::Invalid(format!( + "not supported yet: the recess at {} is {:.3} mm deep, more than half the plate's {:.3} mm; shallower recesses (an engraving, a label) become pockets", + pt(deep.2.at), + depth_of(deep.1), + t + ))); + } + let face_area = if up { top } else { bottom }; + if floors.iter().map(|f| f.2.area).sum::() > face_area { + return Err(DocError::Invalid(format!( + "not supported yet: converting a plate whose recesses are larger than the face they are in (a plate thinner than {t:.3} mm, raised in places)" + ))); + } + // The floors' outlines, from a mesh fine enough to mark them by. + let fine = body.tessellate((t * 1e-2).min(tol).max(1e-4))?; + floors.sort_by(|a, b| depth_of(a.1).total_cmp(&depth_of(b.1))); + // Floors at one depth are one pocket where they meet. + let mut groups: Vec<(f64, Vec)> = Vec::new(); + for (f, d, ..) in &floors { + let on: Vec<[u32; 3]> = fine.triangles.iter().zip(&fine.tri_face).filter(|(_, g)| **g as usize == *f).map(|(tri, _)| *tri).collect(); + let mut loops = boundary_loops(&fine, plane, &on); + loops.sort_by(|a, b| b.signed_area().abs().total_cmp(&a.signed_area().abs())); + let mut it = loops.into_iter(); + let Some(outline) = it.next() else { continue }; + let region = Region2 { outer: Loop2::polygon(&outline).ccw(), holes: it.map(|l| Loop2::polygon(&l).ccw().reversed()).collect() }; + match groups.last_mut() { + Some((depth, regions)) if depth_of(*d) - *depth <= eps => regions.push(region), + _ => groups.push((depth_of(*d), vec![region])), + } + } + // The sheet's z starts from the picked face when its plane faces away from that face. + let from_zero = up == (plane.normal().dot(n) < 0.0); + let mut work = sheet::FlatWork::new(); + let (mut pockets, mut volume) = (Vec::new(), 0.0); + for (depth, regions) in groups { + let (z0, z1) = if from_zero { (0.0, depth) } else { (t - depth, t) }; + for region in sheet::material_union(regions, &mut work)? { + volume += region.area().abs() * depth; + pockets.push((region, z0, z1)); + } + } + Ok(Recesses { level: if up { lo } else { hi }, pockets, volume }) } - Ok(Recesses { level: if up { lo } else { hi }, pockets, volume }) } /// The body a placed plastic feature joins: by name, else the one under the point. @@ -2033,7 +2074,8 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap, f: &F let plane = Plane::from_normal(face.0, n).ok_or_else(|| DocError::Invalid("rim plane".into()))?; let tol = (mb.body.size() * 1e-3).max(1e-3); let mesh = mb.body.tessellate(tol)?; - let mut loops = face_loops(&mesh, &plane, face.0, n, 0.0, tol); + let slice = FaceSlice { mesh: &mesh, plane, at: face.0, n, tol }; + let mut loops = slice.loops(0.0); loops.sort_by(|a, b| b.signed_area().abs().total_cmp(&a.signed_area().abs())); let ccw = |l: &Ring| if l.signed_area() < 0.0 { l.iter().rev().copied().collect::>() } else { l.clone() }; // The inner edge is an opening (the cavity under a rim): not the foot of a boss or a diff --git a/crates/doc/src/face_anchor.rs b/crates/doc/src/face_anchor.rs index d32f962c11..4f502b1ae7 100644 --- a/crates/doc/src/face_anchor.rs +++ b/crates/doc/src/face_anchor.rs @@ -211,8 +211,16 @@ impl Document { let indices = self.configuration_face_indices(body, &target)?; // A copy a pattern or mirror made on the same body carries its seed's certificate, // which finds the seed's face: the copy is attached by its own history name below. + // A face that is no copy, on the plane of the pieces the certificate finds, is + // another piece of that face (a piece a later join left bordering another body, + // and named after it): the certificate stands for it too (#9465). if !indices.contains(&index) { - return Err(DocError::Invalid("this face has no supported construction certificate; choose a fixed plane instead".into())); + let names = crate::naming::names(body); + let copy = names.faces.get(index).is_none_or(|name| crate::persistent_name::is_copy(name)); + let all: Vec = indices.iter().copied().chain([index]).collect(); + if copy || self.coplanar_pieces(body, &all, |_| true).is_err() { + return Err(DocError::Invalid("this face has no supported construction certificate; choose a fixed plane instead".into())); + } } self.coplanar_pieces(body, &indices, |_| true)?; Ok(target) diff --git a/crates/doc/src/format/v46/align-axis-mask.rs b/crates/doc/src/format/v46/align-axis-mask.rs new file mode 100644 index 0000000000..e79d666c17 --- /dev/null +++ b/crates/doc/src/format/v46/align-axis-mask.rs @@ -0,0 +1,5 @@ +//! A point-to-point Align can save `axes`: the directions it moves along, as the chosen world +//! X/Y/Z axes in the frame of the bodies being moved (if it is absent, all three axes are used and +//! the offset is applied in full). Older designs need no transformation: they align on all three +//! axes as before. The last release refuses a design that has this (it refuses the newer format +//! number) rather than moving the bodies on all three axes. diff --git a/crates/doc/src/format/v46/equation-variable-unit.rs b/crates/doc/src/format/v46/equation-variable-unit.rs new file mode 100644 index 0000000000..d94a59a949 --- /dev/null +++ b/crates/doc/src/format/v46/equation-variable-unit.rs @@ -0,0 +1,5 @@ +//! An equation curve can save `variable_unit: "range"`: its variable is a length, as its range is, +//! instead of a plain number (#9333). An older design has none, and its variable stays a plain +//! number, as before; a curve with a plain variable saves nothing new. The last release refuses a +//! design with this change and asks for an update, instead of opening it and losing the key on the +//! next save. diff --git a/crates/doc/src/format/v46/link-file-digests.rs b/crates/doc/src/format/v46/link-file-digests.rs new file mode 100644 index 0000000000..bb367c5305 --- /dev/null +++ b/crates/doc/src/format/v46/link-file-digests.rs @@ -0,0 +1,5 @@ +//! A linked component's link saves `file_sha256`, the SHA-256 of the linked design file's bytes +//! as the assembly last read them, so an assembly opened again knows which linked designs changed +//! while it was closed (#9739). Older designs read unchanged: their links have no digest and are +//! taken as up to date until Update Links reads them. The last release refuses the newer format +//! and asks for an update instead of dropping the digests. diff --git a/crates/doc/src/lineage.rs b/crates/doc/src/lineage.rs index 169d541e8a..770deffe39 100644 --- a/crates/doc/src/lineage.rs +++ b/crates/doc/src/lineage.rs @@ -571,7 +571,12 @@ pub(crate) fn order_lumps(bodies: &mut [crate::ModelBody], origin: &Arc 1) { let feature = members.first().and_then(|i| bodies.get(*i)).map(|b| b.feature); - if members.iter().any(|i| bodies.get(*i).map(|b| b.feature) != feature) { + // A cut's pieces past the first belong to the cut itself (#10123), so its family spans + // two features: the piece that kept the body's name comes from the body's own feature + // and the rest from this feature. Any other mix is not a split's family: leave it be. + let same = members.iter().all(|i| bodies.get(*i).map(|b| b.feature) == feature); + let foreign = members.iter().filter(|i| bodies.get(**i).map(|b| b.feature) != Some(origin.feature.id)).count(); + if !same && foreign > 1 { continue; } let least = |b: &crate::ModelBody| { diff --git a/crates/doc/src/params.rs b/crates/doc/src/params.rs index 78e6b2c869..b480999fac 100644 --- a/crates/doc/src/params.rs +++ b/crates/doc/src/params.rs @@ -462,7 +462,7 @@ impl Document { /// User parameters as CSV (`name,unit,expression,value,comment`). pub fn params_csv(&self) -> String { - let q = |s: &str| if s.contains([',', '"', '\n']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() }; + let q = |s: &str| if s.contains([',', '"', '\r', '\n']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() }; let mut out = String::from("name,unit,expression,value,comment,favorite\n"); for r in self.param_rows().into_iter().filter(|r| r.source == "user") { out += &format!( @@ -497,23 +497,25 @@ impl Document { }) .collect::>()? } else { - let mut lines = text.lines(); - let header: Vec = parse_csv_line(lines.next().unwrap_or_default()).into_iter().map(|h| h.trim().to_ascii_lowercase()).collect(); - let col = |n: &str| header.iter().position(|h| h == n); + let mut records = split_csv(text.strip_prefix('\u{feff}').unwrap_or(text), '\n'); + let header = csv_fields(records.next().unwrap_or_default())?; + let col = |n: &str| header.iter().position(|h| h.trim().eq_ignore_ascii_case(n)); let (Some(ni), Some(ei)) = (col("name"), col("expression").or(col("value"))) else { return Err(DocError::Invalid("the CSV needs `name` and `expression` columns".into())); }; let (ui, ci) = (col("unit"), col("comment")); - lines - .filter(|l| !l.trim().is_empty()) - .map(|l| { - let c = parse_csv_line(l); + records + .filter(|record| !record.trim().is_empty()) + .take(MAX_PARAMS + 1) + .map(|record| { + let c = csv_fields(record)?; let get = |i: Option| i.and_then(|i| c.get(i)).map(|s| s.trim().to_string()); - (get(Some(ni)).unwrap_or_default(), get(Some(ei)).unwrap_or_default(), get(ui), get(ci)) + let comment = ci.and_then(|i| c.get(i)).cloned(); + Ok((get(Some(ni)).unwrap_or_default(), get(Some(ei)).unwrap_or_default(), get(ui), comment)) }) - .collect() + .collect::>()? }; - if rows.len() > 10_000 { + if rows.len() > MAX_PARAMS { return Err(DocError::Invalid("too many parameters".into())); } // Each row is checked and applied as `change_param` would, but the parameters are @@ -1069,25 +1071,32 @@ impl Document { } } -/// One CSV line with quoted fields. -fn parse_csv_line(l: &str) -> Vec { - let mut out = Vec::new(); - let mut cur = String::new(); +/// Split records or fields only outside quotes. A doubled quote toggles twice +/// before the next separator, leaving it inside the same field. +fn split_csv(text: &str, separator: char) -> impl Iterator { let mut quoted = false; - let mut chars = l.chars().peekable(); - while let Some(c) = chars.next() { - match (c, quoted) { - ('"', true) if chars.peek() == Some(&'"') => { - cur.push('"'); - chars.next(); - } - ('"', _) => quoted = !quoted, - (',', false) => out.push(std::mem::take(&mut cur)), - (c, _) => cur.push(c), + text.split(move |c| { + if c == '"' { + quoted = !quoted; } - } - out.push(cur); - out + c == separator && !quoted + }) +} + +fn csv_fields(record: &str) -> Result> { + split_csv(record, ',') + .map(|raw| { + let field = raw.trim(); + if !field.contains('"') { + return Ok(field.to_string()); + } + let inner = field.strip_prefix('"').and_then(|s| s.strip_suffix('"')); + match inner { + Some(s) if s.split("\"\"").all(|part| !part.contains('"')) => Ok(s.replace("\"\"", "\"")), + _ => Err(DocError::Invalid("invalid quoting in parameter CSV".into())), + } + }) + .collect() } /// Parameters whose definitions depend on each other in a loop. diff --git a/crates/doc/src/placement_reuse.rs b/crates/doc/src/placement_reuse.rs index ca861e19de..efa71dc232 100644 --- a/crates/doc/src/placement_reuse.rs +++ b/crates/doc/src/placement_reuse.rs @@ -31,6 +31,7 @@ impl Model { return false; } self.evaluate(doc); + self.mark_fingerprinted(doc); if !self.no_cache && self.primed.is_none() && self.placement_local { self.placement_inputs = Some(Inputs { document: doc.clone(), state: Arc::downgrade(&self.state()) }); } @@ -38,13 +39,16 @@ impl Model { } /// [`Model::refingerprint`] for a shared document: a proof that held for the results before - /// is kept for `doc`, since nothing was built differently (pinned body names, say). - pub fn refingerprint_shared(&mut self, doc: &Arc) { + /// is kept for `doc`, since nothing was built differently (pinned body names, say). The + /// first `keep` results keep their fingerprints: [`Model::pinned_prefix`] of the document + /// before the pins, or 0. + pub fn refingerprint_shared(&mut self, doc: &Arc, keep: usize) { let proven = !self.no_cache && self.primed.is_none() && self.placement_inputs.as_ref().is_some_and(|inputs| inputs.state.upgrade().is_some_and(|state| Arc::ptr_eq(&state, &self.state()))) && !self.is_stale(); - self.refingerprint(doc); + self.refingerprint_from(doc, keep); + self.mark_fingerprinted(doc); if proven && self.placement_local { self.placement_inputs = Some(Inputs { document: doc.clone(), state: Arc::downgrade(&self.state()) }); } diff --git a/crates/doc/src/sketch_equation.rs b/crates/doc/src/sketch_equation.rs index dcfdbcbbc3..ce5fe0e2d3 100644 --- a/crates/doc/src/sketch_equation.rs +++ b/crates/doc/src/sketch_equation.rs @@ -8,7 +8,7 @@ use std::collections::BTreeMap; use solidcraft_geom::Vec2; -use solidcraft_sketch::{EquationCurve, MAX_COORD, MAX_EQUATION_TEXT, MAX_SPLINE_POINTS, Sketch, fit_beziers_mode}; +use solidcraft_sketch::{EquationCurve, MAX_COORD, MAX_EQUATION_TEXT, MAX_SPLINE_POINTS, Sketch, VariableUnit, fit_beziers_mode}; use crate::expr::{self, Defaults, Kind, Value}; use crate::{DocError, Result}; @@ -36,8 +36,13 @@ pub struct EquationFit { pub deviation: f64, } -/// How bare numbers read in the formulas: angles in radians (the variable is a plain number, -/// `sin(t)` its sine), lengths in the design's unit. +/// Why a range cannot give the variable its unit (#9333): a length and an angle have no unit in +/// common, and two plain numbers have none to give. +const RANGE_UNIT_REFUSED: &str = + "with the variable in its range's unit, start and end must both be lengths, or one a length and the other a plain number"; + +/// How bare numbers read in the formulas: angles in radians (a plain variable's `sin(t)` is its +/// sine), lengths in the design's unit. fn defaults(vals: &BTreeMap) -> Defaults { let len = vals.get(crate::eval::DESIGN_LEN).map(|v| v.v).filter(|v| v.is_finite() && *v > 0.0).unwrap_or(1.0); Defaults { len, ang: 1.0 } @@ -54,31 +59,45 @@ fn checked_text<'a>(what: &str, e: &'a str) -> Result<&'a str> { Ok(e) } +/// The variable as the formulas see it, where it is `at` (a number, or the design's unit). +fn variable_value(eq: &EquationCurve, at: f64, d: Defaults) -> Value { + match eq.variable_unit { + VariableUnit::Number => Value::num(at), + VariableUnit::Range => Value::length(at * d.len), + } +} + /// A formula's value (mm) where the variable is `at`. The variable hides a parameter of its name. -fn formula(vals: &BTreeMap, e: &str, variable: &str, at: f64, d: Defaults) -> Result { +fn formula(vals: &BTreeMap, e: &str, variable: &str, at: Value, d: Defaults) -> Result { let lookup = |n: &str| { - if n == variable { Ok(Value::num(at)) } else { vals.get(n).copied().ok_or_else(|| DocError::Expr(format!("unknown parameter `{n}`"))) } + if n == variable { Ok(at) } else { vals.get(n).copied().ok_or_else(|| DocError::Expr(format!("unknown parameter `{n}`"))) } }; let v = expr::eval_with_defaults(e, &lookup, d)?.to_kind_in(Kind::Length, d)?; if v.is_finite() && v.abs() < MAX_COORD { Ok(v) } else { Err(DocError::Expr("the curve goes out of range".into())) } } -/// Where the variable starts or ends, as the plain number the formulas see: a number, an angle -/// (in radians) or a length (in the design's unit). -fn range_end(vals: &BTreeMap, e: &str, d: Defaults) -> Result { +/// Where the variable starts or ends, as a number: a number, an angle (in radians) or a length +/// (in the design's unit); and which of them it is. +fn range_end(vals: &BTreeMap, e: &str, d: Defaults) -> Result<(f64, Kind)> { let v = expr::eval_with_defaults(e, &|n| vals.get(n).copied().ok_or_else(|| DocError::Expr(format!("unknown parameter `{n}`"))), d)?; - let out = match v.kind() { - Some(Kind::Unitless | Kind::Angle) => v.v, - Some(Kind::Length) => v.v / d.len, + let (out, kind) = match v.kind() { + Some(kind @ (Kind::Unitless | Kind::Angle)) => (v.v, kind), + Some(Kind::Length) => (v.v / d.len, Kind::Length), None => return Err(DocError::Expr(format!("expected a number, an angle or a length, got {}", v.describe()))), }; - if out.is_finite() { Ok(out) } else { Err(DocError::Expr("not a finite number".into())) } + if out.is_finite() { Ok((out, kind)) } else { Err(DocError::Expr("not a finite number".into())) } } /// The variable's range, checked. fn range(vals: &BTreeMap, eq: &EquationCurve, d: Defaults) -> Result<(f64, f64)> { - let a = range_end(vals, checked_text("the start", &eq.start)?, d).map_err(|e| DocError::Expr(format!("start: {e}")))?; - let b = range_end(vals, checked_text("the end", &eq.end)?, d).map_err(|e| DocError::Expr(format!("end: {e}")))?; + let (a, start) = range_end(vals, checked_text("the start", &eq.start)?, d).map_err(|e| DocError::Expr(format!("start: {e}")))?; + let (b, end) = range_end(vals, checked_text("the end", &eq.end)?, d).map_err(|e| DocError::Expr(format!("end: {e}")))?; + // A plain number beside a length is in the design's unit, as `range_end` already reads it. + if eq.variable_unit == VariableUnit::Range + && !matches!((start, end), (Kind::Length, Kind::Length | Kind::Unitless) | (Kind::Unitless, Kind::Length)) + { + return Err(DocError::Invalid(RANGE_UNIT_REFUSED.into())); + } if (b - a).abs() <= 1e-12 * a.abs().max(b.abs()).max(1.0) { return Err(DocError::Invalid("the start and the end of the range must differ".into())); } @@ -91,8 +110,9 @@ fn range(vals: &BTreeMap, eq: &EquationCurve, d: Defaults) -> Res /// The curve's point where the variable is `at`. fn point_at(vals: &BTreeMap, eq: &EquationCurve, at: f64, d: Defaults) -> Result { let variable = eq.variable(); + let value = variable_value(eq, at, d); let x = match &eq.x { - Some(e) => formula(vals, e, variable, at, d).map_err(|e| DocError::Expr(format!("x: {e}")))?, + Some(e) => formula(vals, e, variable, value, d).map_err(|e| DocError::Expr(format!("x: {e}")))?, // The explicit form: x is the variable, in the design's unit. None => { let x = at * d.len; @@ -102,7 +122,7 @@ fn point_at(vals: &BTreeMap, eq: &EquationCurve, at: f64, d: Defa x } }; - let y = formula(vals, &eq.y, variable, at, d).map_err(|e| DocError::Expr(format!("y: {e}")))?; + let y = formula(vals, &eq.y, variable, value, d).map_err(|e| DocError::Expr(format!("y: {e}")))?; Ok(Vec2::new(x, y)) } @@ -286,9 +306,24 @@ mod tests { start: start.into(), end: end.into(), tolerance: DEFAULT_TOLERANCE, + variable_unit: VariableUnit::Number, } } + /// The same curve with its variable in its range's unit. + fn with_range_unit(eq: EquationCurve) -> EquationCurve { + EquationCurve { variable_unit: VariableUnit::Range, ..eq } + } + + fn with_a(mm: f64, design_len: Option) -> BTreeMap { + let mut vals = BTreeMap::new(); + vals.insert("a".to_string(), Value::length(mm)); + if let Some(len) = design_len { + vals.insert(crate::eval::DESIGN_LEN.to_string(), Value::num(len)); + } + vals + } + #[test] fn a_circle_closes_and_stays_within_the_tolerance() { let vals = BTreeMap::new(); @@ -360,4 +395,92 @@ mod tests { let long = "t + ".repeat(400) + "t"; assert!(fit(&vals, &eq(Some(&long), "t", "0", "1")).is_err()); } + + /// The issue's parabola (#9333): y = a^2 / (4 t) over t from a to 3a, where t is a length. + /// Every point is exact, and the curve is the same in millimetres whatever the design's unit. + #[test] + fn a_variable_in_its_ranges_unit_gives_the_exact_parabola_in_any_design_unit() { + for design_len in [None, Some(25.4)] { + let vals = with_a(10.0, design_len); + let e = with_range_unit(eq(Some("t"), "a ^ 2 / (4 * t)", "a", "3 * a")); + let fit = fit(&vals, &e).unwrap(); + let (first, last) = (fit.points[0], *fit.points.last().unwrap()); + assert!((first - Vec2::new(10.0, 2.5)).len() < 1e-9, "{design_len:?} {first:?}"); + assert!((last - Vec2::new(30.0, 100.0 / 120.0)).len() < 1e-9, "{design_len:?} {last:?}"); + for p in &fit.points { + assert!((p.y - 100.0 / (4.0 * p.x)).abs() < 1e-9 && (10.0 - 1e-9..=30.0 + 1e-9).contains(&p.x), "{design_len:?} {p:?}"); + } + assert!(fit.deviation <= DEFAULT_TOLERANCE); + } + } + + /// The explicit form names its variable x; bare numbers beside it are in the design's unit. + #[test] + fn the_explicit_form_and_bare_numbers_follow_the_design_unit() { + let vals = with_a(25.4, Some(25.4)); + let fit = fit(&vals, &with_range_unit(eq(None, "x + 1", "0", "a"))).unwrap(); + let (first, last) = (fit.points[0], *fit.points.last().unwrap()); + assert!((first - Vec2::new(0.0, 25.4)).len() < 1e-9, "{first:?}"); + assert!((last - Vec2::new(25.4, 50.8)).len() < 1e-9, "{last:?}"); + } + + /// The rule the issue settled for the range's two ends (#9333). + #[test] + fn a_range_gives_its_unit_only_from_lengths_and_plain_numbers_beside_them() { + // A plain number beside a length is in the design's unit, on either side. + for (design_len, unit) in [(None, 1.0), (Some(25.4), 25.4)] { + let vals = with_a(100.0, design_len); + let up = fit(&vals, &with_range_unit(eq(Some("t"), "t / 2", "1", "a"))).unwrap(); + assert!((up.points[0] - Vec2::new(unit, unit / 2.0)).len() < 1e-9, "{:?}", up.points[0]); + assert!((*up.points.last().unwrap() - Vec2::new(100.0, 50.0)).len() < 1e-9); + let down = fit(&vals, &with_range_unit(eq(Some("t"), "t / 2", "a", "1"))).unwrap(); + assert!((down.points[0] - Vec2::new(100.0, 50.0)).len() < 1e-9); + assert!((*down.points.last().unwrap() - Vec2::new(unit, unit / 2.0)).len() < 1e-9); + } + // Nothing else: a length and an angle, and ends with no length to give. + let vals = with_a(10.0, None); + for (a, b) in [("a", "90 deg"), ("90 deg", "a"), ("0 deg", "90 deg"), ("0", "90 deg"), ("90 deg", "0"), ("0", "1")] { + let why = fit(&vals, &with_range_unit(eq(Some("t"), "t", a, b))).unwrap_err().to_string(); + assert!( + why.contains( + "with the variable in its range's unit, start and end must both be lengths, or one a length and the other a plain number" + ), + "{a}..{b}: {why}" + ); + } + // Off, the same ranges build, with the variable a plain number. + assert!(fit(&vals, &eq(Some("t"), "t", "a", "90 deg")).is_ok()); + } + + /// Off (#9333): the variable is a plain number over a length range, as saved curves expect. + #[test] + fn without_the_flag_the_variable_stays_a_plain_number() { + let vals = with_a(10.0, None); + let plain = fit(&vals, &eq(Some("t"), "t * t / 20", "0 mm", "40 mm")).unwrap(); + let last = *plain.points.last().unwrap(); + assert!((last - Vec2::new(40.0, 80.0)).len() < 1e-9, "{last:?}"); + assert!(fit(&vals, &eq(Some("t"), "a ^ 2 / (4 * t)", "a", "3 * a")).unwrap_err().to_string().contains("length^2")); + // On, that plain-number formula is a length squared, and is refused rather than misread. + assert!(fit(&vals, &with_range_unit(eq(Some("t"), "t * t / 20", "0 mm", "40 mm"))).is_err()); + } + + #[test] + fn hostile_formulas_with_the_variable_in_its_ranges_unit_are_refused() { + let vals = with_a(10.0, Some(25.4)); + for (x, y, a, b) in [ + ("t", "a ^ 2 / (4 * t)", "0", "a"), + ("t", "sqrt(t)", "a", "3 * a"), + ("t", "t ^ 1000", "a", "3 * a"), + ("t", "t", "a", "a"), + ("t", "t", "0", "1e300 mm"), + ("t", "t", "a", "1e308 in"), + ("t", "t", "a", "nan"), + ("t", "t", "a * a", "3 * a"), + ("1e300 * t", "t", "a", "3 * a"), + ("sin(t)", "t", "a", "3 * a"), + ("t", "unknown_name * t", "a", "3 * a"), + ] { + assert!(fit(&vals, &with_range_unit(eq(Some(x), y, a, b))).is_err(), "{x} {y} {a}..{b}"); + } + } } diff --git a/crates/engine/src/cmd/appearance_chain.rs b/crates/engine/src/cmd/appearance_chain.rs index cd43174241..cb48737c6b 100644 --- a/crates/engine/src/cmd/appearance_chain.rs +++ b/crates/engine/src/cmd/appearance_chain.rs @@ -21,16 +21,20 @@ fn component(s: &Session, cmd: &str, value: &Value) -> Result { Value::Number(_) => value.as_u64().ok_or_else(|| bad(cmd, "component must be a name or unsigned id"))?.to_string(), _ => return Err(bad(cmd, "component must be a name or unsigned id")), }; - s.doc.find_component(&key).filter(|id| *id != 0).ok_or_else(|| bad(cmd, "unknown or root component")) + // A placement's name (`K:2`) means its component, as appearance.assign takes it (#9740). + s.component_or_placement(&key).ok_or_else(|| bad(cmd, "unknown or root component")) } pub(super) fn chain(s: &mut Session, p: &Value) -> Result { let cmd = "appearance.chain"; let target = p.get("target").and_then(Value::as_object).ok_or_else(|| bad(cmd, "give one target object"))?; - let target = match (target.get("body"), target.get("face"), target.get("component")) { - (Some(body), None, None) if target.len() == 1 => AppearanceTarget::Body(name(cmd, body)?), + // A placed copy's name (`KB (K:2)`) means the body it shows, as appearance.assign takes it + // (#9740); a name that is neither is left for the chain to refuse. + let body = target.get("body").map(|b| name(cmd, b).map(|n| s.own_body_name(n).unwrap_or_else(|| n.to_string()))).transpose()?; + let target = match (body.as_deref(), target.get("face"), target.get("component")) { + (Some(body), None, None) if target.len() == 1 => AppearanceTarget::Body(body), (Some(body), Some(face), None) if target.len() == 2 => AppearanceTarget::Face { - body: name(cmd, body)?, + body, face: face.as_u64().and_then(|f| usize::try_from(f).ok()).ok_or_else(|| bad(cmd, "face must be a zero-based unsigned index"))?, }, (None, None, Some(id)) if target.len() == 1 => AppearanceTarget::Component(component(s, cmd, id)?), @@ -80,12 +84,18 @@ fn scope(s: &Session, cmd: &str, value: &Value) -> Result { if object.contains_key("bodies") { let state = s.model.state(); let bodies: BTreeSet<_> = state.bodies.iter().map(|b| b.name.as_str()).collect(); + // A placed copy's name means the body it shows (#9740). Built once, and only when a + // name is not the model's own. + let mut placed: Option> = None; for body in list("bodies")? { let body = name(cmd, body)?; - if !bodies.contains(body) { - return Err(bad(cmd, "unknown body in scope")); - } - scope.bodies.insert(body.into()); + let own = if bodies.contains(body) { + body.to_string() + } else { + let placed = placed.get_or_insert_with(|| s.body_frames().iter().map(|(world, own, _)| (world.clone(), own.clone())).collect()); + placed.get(body).cloned().ok_or_else(|| bad(cmd, "unknown body in scope"))? + }; + scope.bodies.insert(own); } } if object.contains_key("components") { @@ -93,11 +103,13 @@ fn scope(s: &Session, cmd: &str, value: &Value) -> Result { // 10000-component design once per target. let ids: BTreeSet<_> = s.doc.components.iter().map(|c| c.id).collect(); let names: BTreeMap<_, _> = s.doc.components.iter().rev().map(|c| (c.name.as_str(), c.id)).collect(); + // A placement's name (`K:2`) means its component (#9740). + let placements: BTreeMap<_, _> = s.doc.occurrences.iter().rev().map(|o| (o.name.as_str(), o.component)).collect(); for target in list("components")? { let id = match target { Value::String(_) => { let key = name(cmd, target)?; - key.parse::().ok().filter(|id| ids.contains(id)).or_else(|| names.get(key).copied()) + key.parse::().ok().filter(|id| ids.contains(id)).or_else(|| names.get(key).copied()).or_else(|| placements.get(key).copied()) } Value::Number(_) => target.as_u64().filter(|id| ids.contains(id)), _ => None, diff --git a/crates/engine/src/cmd/bom.rs b/crates/engine/src/cmd/bom.rs index e1e5ff7347..eb3c745926 100644 --- a/crates/engine/src/cmd/bom.rs +++ b/crates/engine/src/cmd/bom.rs @@ -41,8 +41,8 @@ pub static COMMANDS: &[CommandSpec] = &[ revision, each, count, definition}], not as rows of their own); path?: write a .csv or .json file (children as items 1.1, \ 1.2…); format?: csv|json (default from the path). Each component's bom_type counts: phantom has no row (what it holds \ counts here), reference counts nowhere, purchased and inseparable are one row (in flat, an inseparable one's purchased \ - parts are listed too) → rows: [{item, part_number, description, revision, bom_type, count, definition, components, \ - properties, children?}] sorted by part number, total_occurrences (children included)", + parts are listed too, under it with group_children and a part number) → rows: [{item, part_number, description, revision, \ + bom_type, count, definition, components, properties, children?}] sorted by part number, total_occurrences (children included)", ), ]; @@ -280,11 +280,12 @@ fn group_key(g: &str, cmd: &str) -> Result { /// `Top`, the direct children; in `Flat`, the parts at any depth (a part in a subassembly placed /// twice counts twice), not below a purchased part, nor below a numbered part when `fold`, and of /// what an inseparable part holds only its purchased parts. A phantom's contents count where the -/// phantom is placed; a reference counts nowhere, and nothing inside it does. -fn counts(ix: &Index, root: u64, levels: Levels, fold: bool, cmd: &str) -> Result> { +/// phantom is placed; a reference counts nowhere, and nothing inside it does. `walk` chooses +/// whether the root's contents are listed normally or as an inseparable part's purchased children. +fn counts(ix: &Index, root: u64, levels: Levels, fold: bool, walk: Walk, cmd: &str) -> Result> { let mut out: BTreeMap = BTreeMap::new(); let mut placed = 0usize; - let mut stack: Vec<(u64, usize, Walk)> = vec![(root, 0, Walk::Listed)]; + let mut stack: Vec<(u64, usize, Walk)> = vec![(root, 0, walk)]; while let Some((comp, depth, walk)) = stack.pop() { for child in ix.children.get(&comp).into_iter().flatten() { placed += 1; @@ -303,7 +304,7 @@ fn counts(ix: &Index, root: u64, levels: Levels, fold: bool, cmd: &str) -> Resul } (BomType::Inseparable, Walk::Listed) => { *out.entry(*child).or_default() += 1; - if levels == Levels::Flat && inside { + if levels == Levels::Flat && inside && !(fold && ix.numbered.contains(child)) { stack.push((*child, depth + 1, Walk::Purchased)); } } @@ -415,13 +416,18 @@ fn bom(s: &mut Session, p: &Value) -> Result { let root = if p.get("component").is_some_and(|v| !v.is_null()) { component_id(s, p, cmd)? } else { 0 }; let o = options(p, cmd)?; let ix = Index::new(s); - let counts = counts(&ix, root, o.levels, o.fold, cmd)?; - // Folding: what one of each numbered part holds, as parts at any depth. A purchased or - // inseparable part already keeps what it holds out of the list. + let counts = counts(&ix, root, o.levels, o.fold, Walk::Listed, cmd)?; + // Folding: what one of each numbered part holds, as parts at any depth. Inseparable parts + // list only purchased descendants; purchased parts keep all their contents out of the list. let mut kits: BTreeMap> = BTreeMap::new(); if o.fold { - for id in counts.keys().filter(|id| ix.numbered.contains(id) && ix.bom_type(**id) == BomType::Normal) { - let kit = self::counts(&ix, *id, Levels::Flat, false, cmd)?; + for id in counts.keys().filter(|id| ix.numbered.contains(id)) { + let walk = match ix.bom_type(*id) { + BomType::Normal => Walk::Listed, + BomType::Inseparable => Walk::Purchased, + _ => continue, + }; + let kit = self::counts(&ix, *id, Levels::Flat, false, walk, cmd)?; if !kit.is_empty() { kits.insert(*id, kit); } diff --git a/crates/engine/src/cmd/bom_tests.rs b/crates/engine/src/cmd/bom_tests.rs index 8dc9f2b150..6b0a5b2e22 100644 --- a/crates/engine/src/cmd/bom_tests.rs +++ b/crates/engine/src/cmd/bom_tests.rs @@ -481,6 +481,91 @@ fn bom_types_decide_what_is_a_row_and_what_counts_inside_it() { assert_eq!(run(&mut s, "component.metadata", json!({"component": "Gearbox"}))["bom_type"], "normal"); } +#[test] +fn group_children_keeps_an_inseparable_parts_purchased_children_in_its_row() { + let mut s = Session::default(); + for (name, parent, part_number, bom_type) in + [("Sub", "root", "SUB-1", "inseparable"), ("Screw", "Sub", "M2x10", "purchased"), ("Pin", "Sub", "PIN-1", "normal")] + { + run(&mut s, "component.create", json!({"name": name, "parent": parent, "activate": false})); + run(&mut s, "component.metadata.set", json!({"component": name, "part_number": part_number, "bom_type": bom_type})); + } + run(&mut s, "occurrence.copy", json!({"component": "Screw", "parent": "Sub"})); + run(&mut s, "occurrence.copy", json!({"component": "Sub"})); + let plain = run(&mut s, "assembly.bom", json!({})); + assert_eq!(typed(&plain), [row("M2x10", "purchased", 4), row("SUB-1", "inseparable", 2)]); + for levels in ["flat", "top"] { + for group in ["part_number", "definition"] { + let r = run(&mut s, "assembly.bom", json!({"levels": levels, "group_by": [group], "group_children": true})); + assert_eq!(typed(&r), [row("SUB-1", "inseparable", 2)], "{r}"); + assert_eq!(r["total_occurrences"], plain["total_occurrences"]); + let children = r["rows"][0]["children"].as_array().unwrap(); + assert_eq!(children.len(), 1, "the made Pin stays inside the inseparable part: {r}"); + assert_eq!(children[0]["item"], "1.1"); + assert_eq!(children[0]["part_number"], "M2x10"); + assert_eq!(children[0]["bom_type"], "purchased"); + assert_eq!(children[0]["each"], 2); + assert_eq!(children[0]["count"], 4); + } + } + let path = scratch("inseparable-kit").join("bom.csv"); + run(&mut s, "assembly.bom", json!({"group_children": true, "path": path})); + let text = std::fs::read_to_string(path).unwrap(); + let rows: Vec<&str> = text.lines().skip(1).collect(); + assert_eq!(rows, ["\"1\",\"SUB-1\",\"\",\"\",\"2\",\"Sub\"", "\"1.1\",\"M2x10\",\"\",\"\",\"4\",\"Screw\""]); + + // In its own context the component still lists all its parts, regardless of its type. + let own = run(&mut s, "assembly.bom", json!({"component": "Sub", "group_children": true})); + assert_eq!(typed(&own), [row("M2x10", "purchased", 2), row("PIN-1", "normal", 1)]); + // Only numbered parts fold; turning the option off or removing the number keeps the flat list. + run(&mut s, "component.metadata.set", json!({"component": "Sub", "part_number": ""})); + assert_eq!(run(&mut s, "assembly.bom", json!({"group_children": true}))["rows"], run(&mut s, "assembly.bom", json!({}))["rows"]); +} + +#[test] +fn an_inseparable_kit_finds_nested_purchased_parts_without_opening_purchased_or_reference_parts() { + let mut s = Session::default(); + for (name, parent, bom_type) in [ + ("Sub", "root", "inseparable"), + ("Carrier", "Sub", "normal"), + ("Nested", "Carrier", "inseparable"), + ("Bolt", "Nested", "purchased"), + ("Phantom", "Sub", "phantom"), + ("Reference", "Sub", "reference"), + ("Bought", "Sub", "purchased"), + ] { + run(&mut s, "component.create", json!({"name": name, "parent": parent, "activate": false})); + run(&mut s, "component.metadata.set", json!({"component": name, "part_number": name, "bom_type": bom_type})); + } + for parent in ["Phantom", "Reference", "Bought"] { + run(&mut s, "occurrence.copy", json!({"component": "Bolt", "parent": parent})); + } + let plain = run(&mut s, "assembly.bom", json!({})); + assert_eq!(typed(&plain), [row("Bolt", "purchased", 2), row("Bought", "purchased", 1), row("Sub", "inseparable", 1)]); + let folded = run(&mut s, "assembly.bom", json!({"group_children": true})); + assert_eq!(typed(&folded), [row("Sub", "inseparable", 1)]); + assert_eq!(folded["total_occurrences"], plain["total_occurrences"]); + let children = &folded["rows"][0]["children"]; + assert_eq!(children.as_array().unwrap().len(), 2, "{folded}"); + assert_eq!(children[0]["part_number"], "Bolt"); + assert_eq!(children[0]["each"], 2); + assert_eq!(children[1]["part_number"], "Bought"); + assert_eq!(children[1]["each"], 1); + + // Two definitions with the same number but different purchased contents stay separate kits. + run(&mut s, "component.create", json!({"name": "Other", "activate": false})); + run(&mut s, "component.metadata.set", json!({"component": "Other", "part_number": "Sub", "bom_type": "inseparable"})); + run(&mut s, "occurrence.copy", json!({"component": "Bolt", "parent": "Other"})); + let different = run(&mut s, "assembly.bom", json!({"group_children": true})); + assert_eq!(typed(&different), [row("Sub", "inseparable", 1), row("Sub", "inseparable", 1)]); + assert_eq!(different["total_occurrences"], 6); + // Buying the whole parent makes one purchased row, with no children listed. + set_type(&mut s, "Sub", "purchased"); + let bought = run(&mut s, "assembly.bom", json!({"group_children": true})); + let sub = bought["rows"].as_array().unwrap().iter().find(|r| r["definition"] == "Sub").unwrap(); + assert!(sub.get("children").is_none(), "{bought}"); +} + #[test] fn a_phantom_counts_what_it_holds_in_its_parent_merged_with_the_same_parts() { // A fastener set (no bodies) of two screws, placed three times, and one more screw placed directly. diff --git a/crates/engine/src/cmd/browser.rs b/crates/engine/src/cmd/browser.rs index d588fd1471..62f35bf281 100644 --- a/crates/engine/src/cmd/browser.rs +++ b/crates/engine/src/cmd/browser.rs @@ -566,6 +566,10 @@ fn visibility(s: &mut Session, p: &Value) -> Result { s.visibility.hidden_folders.retain(|k| !k.ends_with(&own) && !groups.iter().any(|g| k.ends_with(g.as_str()))); } } + // Only a sketch a feature uses is kept shown on its own: one nothing uses yet is shown anyway, + // and marking it would keep it shown once an Extrude uses it (#9954). + let used: Vec = + items.iter().filter_map(|it| if let Shown::Sketch(id) = it { crate::view::sketch_consumed(s, *id).then_some(*id) } else { None }).collect(); let v = &mut s.visibility; match str_(p, "folder") { Some("bodies" | "all") if visible => { @@ -584,7 +588,7 @@ fn visibility(s: &mut Session, p: &Value) -> Result { Shown::Sketch(id) => { if visible { v.hidden_sketches.retain(|x| x != id); - if !v.shown_sketches.contains(id) { + if used.contains(id) && !v.shown_sketches.contains(id) { v.shown_sketches.push(*id); } } else { @@ -1009,7 +1013,11 @@ pub(crate) fn counted(f: &solidcraft_doc::Feature, i: usize) -> bool { fn rename_body(s: &mut Session, p: &Value) -> Result { let cmd = "body.rename"; - let old = str_(p, "body").ok_or_else(|| bad(cmd, "`body` must be a body name"))?.to_string(); + let given = str_(p, "body").ok_or_else(|| bad(cmd, "`body` must be a body name"))?; + // A placed copy's name (`KB (K:2)`, the name the browser lists a pasted placement's body by) + // means the body it shows, as appearance.assign takes it (#9740): renaming it renames that body, + // in every placement of its component (#10017). + let old = s.own_body_name(given).ok_or_else(|| bad(cmd, format!("no body `{given}`")))?; let name = str_(p, "name").map(str::trim).filter(|n| !n.is_empty() && n.len() <= 128).ok_or_else(|| bad(cmd, "`name` must be 1…128 characters"))?; if name == old { @@ -1134,9 +1142,6 @@ fn rename_body(s: &mut Session, p: &Value) -> Result { return Err(bad(cmd, e)); } d.rename_body_keys(&old, name); - for b in s.visibility.locked_bodies.iter_mut().filter(|b| **b == old) { - *b = name.to_string(); - } s.remember_body_rename(&previous, &old, name); Ok(json!({"body": old, "name": name})) } @@ -1268,6 +1273,31 @@ mod tests { s.model.state().bodies.iter().map(|b| b.name.clone()).collect() } + /// #10017: a pasted placement's body is listed by its placed name (`KB (K:2)`), and Rename + /// sends that name. It renames the body that placement shows, so both placements show the new + /// name; a name that is neither a body's nor a placed copy's is still refused. + #[test] + fn a_placed_copys_body_renames_by_its_placed_name() { + let mut s = Session::default(); + run(&mut s, "component.create", json!({"name": "K"})); + run(&mut s, "solid.box", json!({"body_name": "KB", "length": 10, "width": 10, "height": 10})); + run(&mut s, "occurrence.copy", json!({"component": "K", "translate": [30, 0, 0]})); + // The copy hidden first stays hidden under its new name, and one undo brings both names + // back. + run(&mut s, "browser.visibility", json!({"items": ["KB (K:2)"], "visible": false})); + let r = run(&mut s, "body.rename", json!({"body": "KB (K:2)", "name": "Lid"})); + assert_eq!((r["body"].as_str(), r["name"].as_str()), (Some("KB"), Some("Lid")), "{r}"); + let world = |s: &Session| s.world_state().bodies.iter().map(|b| b.name.clone()).collect::>(); + let renamed = world(&s); + assert!(renamed.iter().any(|n| n == "Lid") && renamed.iter().any(|n| n == "Lid (K:2)"), "{renamed:?}"); + assert!(!renamed.iter().any(|n| n.starts_with("KB")), "{renamed:?}"); + assert!(!s.body_visible("Lid (K:2)") && s.body_visible("Lid"), "{:?}", s.visibility.hidden_bodies); + assert!(s.execute("body.rename", &json!({"body": "Lid (K:9)", "name": "Other"})).is_err()); + run(&mut s, "edit.undo", json!({})); + let back = world(&s); + assert!(back.iter().any(|n| n == "KB") && back.iter().any(|n| n == "KB (K:2)"), "{back:?}"); + } + #[test] fn body_rename_preserves_plastic_rules_and_current_visibility_through_history() { let mut s = Session::default(); @@ -1318,6 +1348,82 @@ mod tests { assert!(!s.body_visible("Stud")); } + /// Two 5 mm boxes, `A` and `B`, as the #9737 repro script makes them. + fn two_boxes(s: &mut Session) { + run(s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "body_name": "A"})); + run(s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [10, 0, 0], "body_name": "B"})); + } + + fn delete_selection(s: &mut Session) -> Value { + let items: Vec = s.selection.iter().map(|x| serde_json::to_value(x).unwrap()).collect(); + match s.execute("selection.delete", &json!({"items": items})) { + Ok(v) => v, + Err(e) => panic!("selection.delete: {e}"), + } + } + + #[test] + fn selection_follows_body_rename_and_delete_hits_the_renamed_body() { + // #9737: after renaming a selected body the selection is the new name, and Delete + // deletes it — the "nothing to delete" and the wrong-body delete are both gone. + let mut s = Session::default(); + two_boxes(&mut s); + run(&mut s, "select.set", json!({"items": [{"type": "body", "name": "A"}]})); + run(&mut s, "body.rename", json!({"body": "A", "name": "Renamed"})); + assert_eq!(s.selection, vec![crate::Sel::Body { name: "Renamed".into() }]); + delete_selection(&mut s); + assert_eq!(names(&s), ["B"]); + // The old name is free; B takes it, and Delete finds nothing — B is never deleted. + run(&mut s, "body.rename", json!({"body": "B", "name": "A"})); + assert!(s.execute("selection.delete", &json!({"items": []})).is_err()); + assert_eq!(names(&s), ["A"]); + } + + #[test] + fn renaming_the_selection_leaves_the_old_name_for_another_body_to_take() { + // The wrong-body delete, head on: B takes A's name before Delete is pressed. The + // selection followed the rename, so Delete takes `Renamed` and B (now `A`) survives. + let mut s = Session::default(); + two_boxes(&mut s); + run(&mut s, "select.set", json!({"items": [{"type": "body", "name": "A"}]})); + run(&mut s, "body.rename", json!({"body": "A", "name": "Renamed"})); + run(&mut s, "body.rename", json!({"body": "B", "name": "A"})); + assert_eq!(s.selection, vec![crate::Sel::Body { name: "Renamed".into() }]); + delete_selection(&mut s); + assert_eq!(names(&s), ["A"]); + } + + #[test] + fn face_selection_follows_the_body_rename() { + let mut s = Session::default(); + two_boxes(&mut s); + let face = json!({"type": "face", "body": "A", "index": 0, "point": [2.5, 2.5, 0]}); + run(&mut s, "select.set", json!({"items": [face]})); + run(&mut s, "body.rename", json!({"body": "A", "name": "Renamed"})); + assert_eq!(s.selection, vec![crate::Sel::Face { body: "Renamed".into(), index: 0, point: [2.5, 2.5, 0.0].into() }]); + } + + #[test] + fn a_selected_body_that_goes_away_leaves_the_selection() { + // A stale selection is how a later body taking the name gets deleted: once the body + // is gone the selection drops it, at the refresh that removed the body. + let mut s = Session::default(); + two_boxes(&mut s); + run(&mut s, "select.set", json!({"items": [{"type": "body", "name": "A"}]})); + run(&mut s, "solid.remove", json!({"bodies": ["A"]})); + assert!(s.selection.is_empty(), "the gone body must not stay selected: {:?}", s.selection); + } + + #[test] + fn select_set_refuses_a_body_name_that_is_not_there() { + let mut s = Session::default(); + two_boxes(&mut s); + assert!(s.execute("select.set", &json!({"items": [{"type": "body", "name": "NoSuchBody"}]})).is_err()); + assert!(s.selection.is_empty()); + run(&mut s, "select.set", json!({"items": [{"type": "body", "name": "A"}, {"type": "body", "name": "B"}]})); + assert_eq!(s.selection.len(), 2); + } + #[test] fn show_all_bodies_clears_stale_hidden_names() { for folder in ["bodies", "all"] { @@ -1350,6 +1456,51 @@ mod tests { assert!(!s.body_visible("Stud")); } + #[test] + fn body_rename_reusing_a_hidden_name_preserves_visibility_and_history() { + for computed in [false, true] { + for hidden in [false, true] { + let mut s = Session::default(); + two_boxes(&mut s); + let retired = s.doc.features[0].id; + run(&mut s, "browser.visibility", json!({"items": ["A"], "visible": false})); + run(&mut s, "timeline.delete", json!({"feature": retired})); + if hidden { + run(&mut s, "browser.visibility", json!({"items": ["B"], "visible": false})); + } + let before = s.visibility.clone(); + assert_eq!(s.body_visible("B"), !hidden); + if computed { + let mut scratch = s.scratch(); + run(&mut scratch, "body.rename", json!({"body": "B", "name": "A"})); + s.accept_computed(scratch); + } else { + run(&mut s, "body.rename", json!({"body": "B", "name": "A"})); + } + assert_eq!(names(&s), ["A"]); + assert_eq!(s.body_visible("A"), !hidden, "computed={computed}, hidden={hidden}: rename preserves visibility"); + assert_eq!(s.visibility.hidden_bodies, if hidden { vec!["A"] } else { vec![] }); + run(&mut s, "edit.undo", json!({})); + assert_eq!(names(&s), ["B"]); + assert_eq!(s.visibility, before, "undo restores the original hidden names, including the deleted body's"); + assert_eq!(s.body_visible("B"), !hidden, "undo must not transfer the stale hide to B"); + if !hidden { + run(&mut s, "edit.undo", json!({})); + assert_eq!(names(&s), ["A", "B"]); + assert!(!s.body_visible("A") && s.body_visible("B"), "restoring the deleted body restores its own visibility"); + run(&mut s, "edit.redo", json!({})); + } + run(&mut s, "edit.redo", json!({})); + assert_eq!(s.body_visible("A"), !hidden, "redo preserves visibility"); + let mut saved = (*s.doc).clone(); + saved.visibility = s.visibility.clone(); + let reopened = Session::new(serde_json::from_str(&serde_json::to_string(&saved).unwrap()).unwrap()); + assert_eq!(names(&reopened), ["A"]); + assert_eq!(reopened.body_visible("A"), !hidden, "reopening preserves visibility"); + } + } + } + #[test] fn renamed_body_keeps_its_users() { let mut s = Session::default(); @@ -1368,6 +1519,82 @@ mod tests { assert_eq!(names(&s)[1], b); } + /// #10123: renaming a piece cut off a frame member must land on that piece after a rebuild, + /// not on the first line's member. The repro is the issue's, verbatim. + #[test] + fn renaming_a_cut_off_frame_piece_lands_on_that_piece_after_rebuild() { + let dir = std::env::temp_dir().join(format!("sc-10123-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let saved = dir.join("rename_repro.sc"); + let mut s = Session::default(); + run(&mut s, "document.units", json!({"units": "in"})); + run( + &mut s, + "timeline.add", + json!({"feature": { + "id": 1, "name": "Lines", "type": "sketch", "suppressed": false, + "plane": {"name": "XY", "type": "origin"}, + "sketch": { + "constraints": [{"id": "k1", "p": 0, "q": 1, "type": "coincident"}], + "counters": {"k": 1, "l": 2}, + "curves": [ + {"a": 1, "b": 2, "construction": false, "id": "l1", "type": "line"}, + {"a": 3, "b": 4, "construction": false, "id": "l2", "type": "line"} + ], + "points": [ + {"fixed": true, "id": "origin", "pos": [0.0, 0.0]}, + {"fixed": false, "id": "l1.start", "pos": [0.0, 0.0]}, + {"fixed": false, "id": "l1.end", "pos": [100.0, 0.0]}, + {"fixed": false, "id": "l2.start", "pos": [0.0, 50.0]}, + {"fixed": false, "id": "l2.end", "pos": [100.0, 50.0]} + ] + } + }}), + ); + run( + &mut s, + "timeline.add", + json!({"feature": { + "id": 2, "name": "Members", "type": "frame_member", "suppressed": false, + "body_names": ["A", "B"], "names": {"l1": "A", "l2": "B"}, "param_names": ["d1"], + "path_sketch": 1, + "profile": {"profile": {"diameter": "10 mm", "kind": "round_bar"}, "source": "library"} + }}), + ); + run( + &mut s, + "timeline.add", + json!({"feature": { + "id": 3, "name": "Cutter", "type": "box", "suppressed": false, + "body_names": ["Cutter"], "corner": [45.0, 40.0, -10.0], + "height": "20 mm", "length": "10 mm", "width": "20 mm", + "operation": "new_body", "param_names": ["d2", "d3", "d4"] + }}), + ); + run( + &mut s, + "timeline.add", + json!({"feature": { + "id": 4, "name": "Cut B", "type": "combine", "suppressed": false, + "operation": "cut", "target": "B", "tools": ["Cutter"], "keep_tools": true + }}), + ); + let sorted = |v: Vec| { + let mut v = v; + v.sort(); + v + }; + assert_eq!(sorted(names(&s)), ["A", "B", "B (1)", "Cutter"]); + run(&mut s, "body.rename", json!({"body": "B (1)", "name": "B2"})); + assert_eq!(sorted(names(&s)), ["A", "B", "B2", "Cutter"]); + run(&mut s, "file.save_as", json!({"path": saved.to_string_lossy()})); + let mut reopened = Session::default(); + run(&mut reopened, "doc.open", json!({"path": saved.to_string_lossy()})); + assert_eq!(sorted(names(&reopened)), ["A", "B", "B2", "Cutter"], "the rename must not move to member A on rebuild"); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn renaming_a_body_to_a_removed_name_preserves_the_design() { for removed_name in ["Base", "Body1"] { @@ -1859,6 +2086,23 @@ mod tests { assert!(s.visibility.hidden_bodies.is_empty()); } + /// #9954: a sketch hidden and shown again before an Extrude uses it hides itself once the + /// Extrude uses it, as a sketch never hidden does; shown after the Extrude, it stays shown. + #[test] + fn a_sketch_shown_before_an_extrude_hides_itself_once_the_extrude_uses_it() { + let mut s = Session::default(); + let sketch = run(&mut s, "sketch.create", json!({"plane": "XY"}))["sketch"].as_u64().unwrap(); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "browser.visibility", json!({"items": [sketch], "visible": false})); + run(&mut s, "browser.visibility", json!({"items": [sketch], "visible": true})); + assert!(crate::view::sketch_shown(&s, sketch)); + run(&mut s, "solid.extrude", json!({"sketch": sketch, "distance": 10})); + assert!(!crate::view::sketch_shown(&s, sketch), "the Extrude uses it: it hides itself"); + run(&mut s, "browser.visibility", json!({"items": [sketch], "visible": true})); + assert!(crate::view::sketch_shown(&s, sketch), "shown on its own, it stays shown"); + } + #[test] fn a_sheet_metal_flange_hides_its_sketch_as_an_extrude_does() { // #6804: the base flange's sketch stayed shown; its line on the plate's bottom edge then @@ -1956,7 +2200,7 @@ mod tests { assert_eq!(lines(&s), n0); // The browser's Sketches folder hidden: sketches shown one by one aren't drawn either, // only the sketch being edited. - assert!(s.visibility.shown_sketches.contains(&floor)); + assert!(crate::view::sketch_shown(&s, floor)); run(&mut s, "browser.visibility", json!({"tree_folders": ["c0/sketches"], "visible": false})); assert!([floor, drain].iter().all(|id| !crate::view::sketch_shown(&s, *id))); s.active_sketch = Some(floor); @@ -2020,7 +2264,8 @@ mod tests { assert!(s.visibility.hidden_bodies.is_empty()); run(&mut s, "browser.visibility", json!({"items": ["BACK LID"], "visible": true})); assert!(s.visibility.hidden_sketches.is_empty()); - assert_eq!(s.visibility.shown_sketches, vec![sketch]); + // Nothing uses the sketch: shown again, it is on neither list (#9954). + assert!(s.visibility.shown_sketches.is_empty() && crate::view::sketch_shown(&s, sketch)); } /// #2219: hiding is a step of its own, so Ctrl+Z brings the body back instead of undoing the diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs index 8ddb0f953a..c8b1a29cd6 100644 --- a/crates/engine/src/cmd/clipboard.rs +++ b/crates/engine/src/cmd/clipboard.rs @@ -8,7 +8,7 @@ use serde_json::{Value, json}; use solidcraft_doc::{FeatureKind, PlaneRef}; use super::CommandSpec; -use crate::params::{bad, name_or_id_list, vec3}; +use crate::params::{bad, name_or_id_values, vec3}; use crate::{Result, Session}; pub static COMMANDS: &[CommandSpec] = &[ @@ -24,14 +24,11 @@ pub static COMMANDS: &[CommandSpec] = &[ fn copy(s: &mut Session, p: &Value) -> Result { let cmd = "timeline.copy"; // The features are named or numbered, and both kinds can sit in one list. - let keys = name_or_id_list(cmd, p, "features")?; + let keys = name_or_id_values(cmd, p, "features")?; if keys.is_empty() || keys.len() > 1000 { return Err(bad(cmd, "`features` must list 1…1000 features")); } - let mut ids = Vec::new(); - for k in &keys { - ids.push(s.doc.find_feature(k).map(|f| f.id).ok_or_else(|| bad(cmd, format!("no feature `{k}`")))?); - } + let ids = keys.iter().map(|key| super::edit::feature_id(s, Some(key), cmd)).collect::>>()?; // Keep timeline order so sketches come before the features that use them. let copied: Vec = s.doc.features.iter().filter(|f| ids.contains(&f.id)).cloned().collect(); let names: Vec = copied.iter().map(|f| f.name.clone()).collect(); diff --git a/crates/engine/src/cmd/component_extract.rs b/crates/engine/src/cmd/component_extract.rs index 4a7022091d..2997a459dc 100644 --- a/crates/engine/src/cmd/component_extract.rs +++ b/crates/engine/src/cmd/component_extract.rs @@ -17,7 +17,7 @@ use serde_json::{Value, json}; use solidcraft_doc::{DesignLink, Document, FeatureKind, ModelBody, ModelState}; use solidcraft_geom::Vec3; -use super::{absolute, component_key, inherit_properties, resolve, session_bodies, stored}; +use super::{absolute, component_key, file_sha256, inherit_properties, resolve, session_bodies, stored}; use crate::params::{bad, str_}; use crate::{EngineError, Result, Session}; @@ -597,7 +597,7 @@ pub(super) fn extract(s: &mut Session, p: &Value) -> Result { f.link_faces = faces; } let path = stored(s.path.as_deref(), &file); - s.doc_mut().links.insert(comp, DesignLink { path: file.to_string_lossy().into_owned(), feature: id, inherited: Vec::new() }); + s.doc_mut().links.insert(comp, DesignLink { path: file.to_string_lossy().into_owned(), feature: id, ..Default::default() }); s.refresh(); rename_joint_faces(s, &state, id, &sc.own); if let Some(e) = s.model.result(id).and_then(|r| r.error.clone()) { @@ -611,6 +611,12 @@ pub(super) fn extract(s: &mut Session, p: &Value) -> Result { part.path = s.path.clone(); part.execute("file.save", &json!({"path": shown}))?; + // The link keeps what the new file holds, as Insert Design's does (#9739). + if let Some(read) = file_sha256(&file, s.path.as_deref()) + && let Some(link) = s.doc_mut().links.get_mut(&comp) + { + link.file_sha256 = Some(read); + } Ok(json!({"component": comp, "feature": id, "path": path, "file": shown, "bodies": sc.bodies, "notices": notices, "warnings": warnings, "copies": named(s, &copies)})) } diff --git a/crates/engine/src/cmd/component_link.rs b/crates/engine/src/cmd/component_link.rs index 32ca56c7cb..8c1cc99927 100644 --- a/crates/engine/src/cmd/component_link.rs +++ b/crates/engine/src/cmd/component_link.rs @@ -35,8 +35,10 @@ pub static COMMANDS: &[CommandSpec] = &[ reuse?: bool (default false): insert a free occurrence, reusing an existing link to the same file when available", ), CommandSpec::new("component.update_links", "Update Links", update_links).icon("update").params( - "component?: id or name (default: every linked component) — reads the linked designs again and replaces their bodies where they changed; \ - → updated, unchanged, warnings (also joints whose faces are gone or that no longer solve)", + "component?: id or name (default: every linked component); components?: a list of ids or names, which goes on past a design it \ + cannot read (that one keeps its bodies, is named in failed and gets a warning) — reads the linked designs again and replaces \ + their bodies where they changed, as one undo step; → updated, unchanged, failed ([{component, name, error}]), warnings (also \ + joints whose faces are gone or that no longer solve)", ), CommandSpec::new("component.relink", "Relink Source", relink).icon("update").params( "component: id or name of a linked component; path: a saved .sc design — replaces the source and linked bodies for all its occurrences, preserving identities and placements; returns path, previous_path, warnings, joint_errors and joint_conflicts", @@ -59,7 +61,8 @@ pub static COMMANDS: &[CommandSpec] = &[ ), CommandSpec::new("component.links", "List Links", list_links).icon("chain").noundo().params( "→ linked components: component, name, path, file (resolved), found (null when the file is on a network location not allowed, \ - or names a device: it is not looked for), and then host (the network location) and reason", + or names a device: it is not looked for), changed (whether the file is no longer what this design last read: Update Links \ + reads it again; null when not found or the link is older than this record), and then host (the network location) and reason", ), ]; @@ -170,9 +173,38 @@ fn read_link(file: &Path, own: Option<&str>) -> Result> { }) } -/// The solid bodies of the design at `file`, evaluated, as an Import feature. -fn linked_bodies(file: &Path, own: Option<&str>) -> Result { - design_bodies(file, &read_link(file, own)?) +/// The solid bodies of the design at `file`, evaluated, as an Import feature, with the digest of +/// the bytes read, which the link keeps ([`DesignLink::file_sha256`], #9739). +fn linked_bodies_read(file: &Path, own: Option<&str>) -> Result<(Linked, String)> { + let bytes = read_link(file, own)?; + Ok((design_bodies(file, &bytes)?, DesignLink::file_digest(&bytes))) +} + +/// Linked files' digests as [`file_sha256`] last worked them out, by file, with the time and +/// size the file had then. Bounded, oldest first out. +static DIGESTS: Mutex> = Mutex::new(Vec::new()); +const DIGESTS_SIZE: usize = 256; + +/// The digest of the design file at `file` now ([`DesignLink::file_digest`]), `None` when it +/// can't be read. `component.links` asks each time a tab is shown, so a file is read again only +/// when its time or size changed since; the browser build keeps no times and reads it each time. +pub(super) fn file_sha256(file: &Path, own: Option<&str>) -> Option { + let file = absolute(file); + let stamp = solidcraft_io::vfs::design_path_stamp(&file.to_string_lossy(), own); + if let Some(stamp) = stamp + && let Some(known) = DIGESTS.lock().ok()?.iter().find(|(f, s, _)| *f == file && *s == stamp).map(|(_, _, d)| d.clone()) + { + return Some(known); + } + let digest = DesignLink::file_digest(&read_link(&file, own).ok()?); + if let (Some(stamp), Ok(mut digests)) = (stamp, DIGESTS.lock()) { + digests.retain(|(f, _, _)| *f != file); + if digests.len() >= DIGESTS_SIZE { + digests.remove(0); + } + digests.push((file, stamp, digest.clone())); + } + Some(digest) } /// The solid bodies of `bytes`, the design at `file`, as an Import feature. The model the file @@ -478,7 +510,7 @@ fn insert_design(s: &mut Session, p: &Value) -> Result { return super::component::copy_occurrence(s, &json!({"component": component, "parent": parent, "translate": translate})); } } - let (design_name, sf, faces, metadata, properties) = linked_bodies(&file, s.path.as_deref())?; + let ((design_name, sf, faces, metadata, properties), read) = linked_bodies_read(&file, s.path.as_deref())?; let name = str_(p, "name").filter(|n| !n.trim().is_empty()).map(str::to_string).unwrap_or(design_name); let mut create = json!({"name": name, "activate": false}); // Clipboard insertion uses reuse even for the first paste, which must stay movable. @@ -505,7 +537,9 @@ fn insert_design(s: &mut Session, p: &Value) -> Result { o.transform[3] = [translate.x, translate.y, translate.z, 1.0]; } let path = stored(s.path.as_deref(), &file); - s.doc_mut().links.insert(comp, DesignLink { path: file.to_string_lossy().into_owned(), feature: id, inherited: Vec::new() }); + s.doc_mut() + .links + .insert(comp, DesignLink { path: file.to_string_lossy().into_owned(), feature: id, inherited: Vec::new(), file_sha256: Some(read) }); s.refresh(); if let Some(e) = s.model.result(id).and_then(|r| r.error.clone()) { return Err(EngineError::Other(e)); @@ -521,14 +555,25 @@ fn insert_design(s: &mut Session, p: &Value) -> Result { fn update_links(s: &mut Session, p: &Value) -> Result { let cmd = "component.update_links"; - let only = if p.get("component").is_some() { Some(component_key(s, p, "component", cmd)?) } else { None }; - if let Some(c) = only - && !s.doc.links.contains_key(&c) - { - return Err(bad(cmd, "that component is not linked to a design")); + let only = match (p.get("component"), p.get("components")) { + (Some(_), Some(_)) => return Err(bad(cmd, "give `component` or `components`, not both")), + (Some(_), None) => Some(vec![component_key(s, p, "component", cmd)?]), + (None, Some(Value::Array(list))) if !list.is_empty() && list.len() <= 10_000 => { + Some(list.iter().map(|c| component_key(s, &json!({"component": c}), "component", cmd)).collect::>>()?) + } + (None, Some(_)) => return Err(bad(cmd, "`components` must be a list of component ids or names")), + (None, None) => None, + }; + if let Some(c) = only.iter().flatten().find(|c| !s.doc.links.contains_key(c)) { + let name = s.doc.components.iter().find(|x| x.id == *c).map_or_else(|| c.to_string(), |x| x.name.clone()); + return Err(bad(cmd, format!("{name}: that component is not linked to a design"))); } - let links: Vec<(u64, DesignLink)> = s.doc.links.iter().filter(|(c, _)| only.is_none_or(|o| o == **c)).map(|(c, l)| (*c, l.clone())).collect(); - let (mut updated, mut unchanged, mut warnings) = (Vec::new(), Vec::new(), Vec::new()); + // A list goes on past a design it cannot read: that link keeps its bodies and is named in + // `failed`, so the others still come in as one step (#9743). + let go_on = p.get("components").is_some(); + let links: Vec<(u64, DesignLink)> = + s.doc.links.iter().filter(|(c, _)| only.as_ref().is_none_or(|o| o.contains(c))).map(|(c, l)| (*c, l.clone())).collect(); + let (mut updated, mut unchanged, mut warnings, mut failed) = (Vec::new(), Vec::new(), Vec::new(), Vec::new()); let count = links.len(); for (index, (comp, link)) in links.into_iter().enumerate() { let cname = s.doc.components.iter().find(|c| c.id == comp).map(|c| c.name.clone()); @@ -539,16 +584,23 @@ fn update_links(s: &mut Session, p: &Value) -> Result { }; // A window running this off its thread names the design being read again (#5329). solidcraft_doc::progress::item(Some((&cname, index, count))); - let Some(FeatureKind::Import { step: old, .. }) = s.doc.feature(link.feature).filter(|f| f.component == comp).map(|f| &f.kind) else { - return Err(bad( - cmd, - format!("{cname}: its linked bodies (feature {}) are gone; break the link or insert the design again", link.feature), - )); + let (old, file, bytes) = match read_linked(s, comp, &link) { + Ok(read) => read, + Err(e) if go_on => { + failed.push(json!({"component": comp, "name": cname, "error": e})); + warnings.push(format!("{cname} keeps its bodies: {e}")); + continue; + } + Err(e) => return Err(bad(cmd, format!("{cname}: {e}"))), }; - let old = old.clone(); - link_refused(s.path.as_deref(), &link.path).map_err(|e| bad(cmd, format!("{cname}: {e}")))?; - let file = resolve(s.path.as_deref(), &link.path); - let bytes = read_link(&file, s.path.as_deref()).map_err(|e| bad(cmd, format!("{cname}: {e}")))?; + // The link keeps what it read, so opening this design again knows whether the file + // changed while it was closed (#9739). + let read = DesignLink::file_digest(&bytes); + if link.file_sha256.as_deref() != Some(read.as_str()) + && let Some(kept) = s.doc_mut().links.get_mut(&comp) + { + kept.file_sha256 = Some(read); + } // A link that has not recorded the properties it inherited (made before they were) is read again. let kept_faces = s .doc @@ -567,7 +619,19 @@ fn update_links(s: &mut Session, p: &Value) -> Result { } continue; } - let (_, sf, faces, _, properties) = design_bodies(&file, &bytes).map_err(|e| bad(cmd, format!("{cname}: {e}")))?; + let (_, sf, faces, _, properties) = match design_bodies(&file, &bytes) { + Ok(read) => read, + Err(e) if go_on => { + // It keeps its bodies, so it has not taken this file in yet. + if let Some(kept) = s.doc_mut().links.get_mut(&comp) { + kept.file_sha256.clone_from(&link.file_sha256); + } + failed.push(json!({"component": comp, "name": cname, "error": e.to_string()})); + warnings.push(format!("{cname} keeps its bodies: {e}")); + continue; + } + Err(e) => return Err(bad(cmd, format!("{cname}: {e}"))), + }; warnings.extend(sf.warnings.iter().cloned()); let same = matches!(&sf.kind, FeatureKind::Import { step, .. } if *step == old); let kept = s.doc.feature(link.feature).map(|f| &f.link_faces); @@ -605,6 +669,11 @@ fn update_links(s: &mut Session, p: &Value) -> Result { if changed { updated.push(cname) } else { unchanged.push(cname) } } solidcraft_doc::progress::item(None); + // None could be read: nothing to take back either. + if updated.is_empty() && unchanged.is_empty() && !failed.is_empty() { + let (name, why) = (failed[0]["name"].as_str().unwrap_or_default(), failed[0]["error"].as_str().unwrap_or_default()); + return Err(bad(cmd, format!("{name}: {why}"))); + } s.refresh(); if let Some(e) = s.doc.links.values().find_map(|l| s.model.result(l.feature).and_then(|r| r.error.clone())) { return Err(EngineError::Other(e)); @@ -614,7 +683,19 @@ fn update_links(s: &mut Session, p: &Value) -> Result { let sol = solidcraft_doc::joints::solve(&s.doc, &s.model.state()); warnings.extend(sol.errors.iter().chain(&sol.conflicts).map(|(_, name, why)| format!("joint {name}: {why}"))); } - Ok(json!({"updated": updated, "unchanged": unchanged, "warnings": warnings})) + Ok(json!({"updated": updated, "unchanged": unchanged, "warnings": warnings, "failed": failed})) +} + +/// `comp`'s linked design as it was last read (its bodies) and as its file is now, read before +/// anything in the design changes. +fn read_linked(s: &Session, comp: u64, link: &DesignLink) -> std::result::Result<(std::sync::Arc, PathBuf, Vec), String> { + let Some(FeatureKind::Import { step: old, .. }) = s.doc.feature(link.feature).filter(|f| f.component == comp).map(|f| &f.kind) else { + return Err(format!("its linked bodies (feature {}) are gone; break the link or insert the design again", link.feature)); + }; + link_refused(s.path.as_deref(), &link.path).map_err(|e| e.to_string())?; + let file = resolve(s.path.as_deref(), &link.path); + let bytes = read_link(&file, s.path.as_deref()).map_err(|e| e.to_string())?; + Ok((old.clone(), file, bytes)) } /// The joints and joint origins on occurrences of `comp` that name its linked faces by their @@ -654,7 +735,7 @@ fn relink(s: &mut Session, p: &Value) -> Result { } // Read/evaluate the candidate before changing anything. Session::execute owns the single // undo step and restores both the document and evaluated state on any later failure. - let (_, sf, faces, _, properties) = linked_bodies(&file, s.path.as_deref()).map_err(|e| bad(cmd, e.to_string()))?; + let ((_, sf, faces, _, properties), read) = linked_bodies_read(&file, s.path.as_deref()).map_err(|e| bad(cmd, e.to_string()))?; let geometry_changed = match (s.doc.feature(link.feature).map(|f| &f.kind), &sf.kind) { (Some(FeatureKind::Import { step: old, .. }), FeatureKind::Import { step, .. }) => old != step, _ => true, @@ -677,7 +758,7 @@ fn relink(s: &mut Session, p: &Value) -> Result { f.kind = sf.kind; f.link_faces = faces; } - s.doc_mut().links.insert(comp, DesignLink { path: file.to_string_lossy().into_owned(), ..link.clone() }); + s.doc_mut().links.insert(comp, DesignLink { path: file.to_string_lossy().into_owned(), file_sha256: Some(read), ..link.clone() }); s.refresh(); if let Some(error) = s.model.result(link.feature).and_then(|r| r.error.clone()) { return Err(bad(cmd, error)); @@ -717,8 +798,17 @@ fn list_links(s: &mut Session, _p: &Value) -> Result { // A refused path is not looked for at all: on Windows asking whether a file on // `\\host\share` exists already connects to the host. match link_refusal(s.path.as_deref(), &l.path) { - None => json!({"component": c, "name": name, "path": path, "file": file, - "found": solidcraft_io::vfs::design_path_is_file(&file, s.path.as_deref())}), + None => { + let found = solidcraft_io::vfs::design_path_is_file(&file, s.path.as_deref()); + // Whether the file is no longer what this design last read (#9739): `null` + // when it isn't found, or the link keeps no digest (made before they were). + let changed = l + .file_sha256 + .as_ref() + .filter(|_| found == Some(true)) + .and_then(|kept| file_sha256(Path::new(file.as_ref()), s.path.as_deref()).map(|now| now != *kept)); + json!({"component": c, "name": name, "path": path, "file": file, "found": found, "changed": changed}) + } Some(solidcraft_io::vfs::Remote::Network(host)) => json!({"component": c, "name": name, "path": path, "file": file, "found": null, "host": host, "reason": format!("on the network location \\\\{host}, which is not allowed")}), Some(solidcraft_io::vfs::Remote::Device) => json!({"component": c, "name": name, "path": path, "file": file, @@ -913,6 +1003,84 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// #9739: a link keeps what it read of its file, so an assembly opened again says which + /// linked designs changed while it was closed; Update Links takes the change and the record. + #[test] + fn a_link_says_its_file_changed_while_the_design_was_closed() { + let dir = std::env::temp_dir().join(format!("solidcraft-link-changed-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let (part, assembly) = (dir.join("p.sc"), dir.join("a.sc")); + save_box(&part, 10.0); + let mut a = Session::default(); + run(&mut a, "component.insert_design", json!({"path": part})); + run(&mut a, "file.save", json!({"path": assembly})); + let changed = |s: &mut Session| run(s, "component.links", json!({}))["links"][0]["changed"].clone(); + assert_eq!(changed(&mut a), json!(false)); + // The part is edited while the assembly is closed. + save_box(&part, 30.0); + let mut opened = Session::default(); + run(&mut opened, "doc.open", json!({"path": assembly})); + assert_eq!(changed(&mut opened), json!(true)); + assert!((volume(&mut opened) - 2000.0).abs() < 1e-6, "{}", volume(&mut opened)); + assert_eq!(run(&mut opened, "component.update_links", json!({}))["updated"], json!(["p"])); + assert_eq!(changed(&mut opened), json!(false)); + assert!((volume(&mut opened) - 6000.0).abs() < 1e-6, "{}", volume(&mut opened)); + // Saved, it opens up to date. + run(&mut opened, "file.save", json!({})); + let mut again = Session::default(); + run(&mut again, "doc.open", json!({"path": assembly})); + assert_eq!(changed(&mut again), json!(false)); + // A link made before the record says nothing; Update Links gives it one. + again.doc_mut().links.values_mut().for_each(|l| l.file_sha256 = None); + assert_eq!(changed(&mut again), Value::Null); + assert_eq!(run(&mut again, "component.update_links", json!({}))["unchanged"], json!(["p"])); + assert_eq!(changed(&mut again), json!(false)); + let _ = std::fs::remove_dir_all(&dir); + } + + /// #9743: a list of links is read in one step that goes on past a design it can't read. + #[test] + fn update_links_given_a_list_reads_them_in_one_step_past_a_design_it_cannot_read() { + let dir = std::env::temp_dir().join(format!("solidcraft-link-list-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let (plate, block) = (dir.join("plate.solidcraft"), dir.join("block.solidcraft")); + save_box(&plate, 30.0); + save_box(&block, 10.0); + let mut a = Session::default(); + let first = run(&mut a, "component.insert_design", json!({"path": plate}))["component"].clone(); + let second = run(&mut a, "component.insert_design", json!({"path": plate, "translate": [50, 0, 0]}))["component"].clone(); + let third = run(&mut a, "component.insert_design", json!({"path": block, "translate": [100, 0, 0]}))["component"].clone(); + assert!((volume(&mut a) - 14000.0).abs() < 1e-6, "{}", volume(&mut a)); + // Two links to the same changed file come in together, and one Undo takes both back. + save_box(&plate, 45.0); + let r = run(&mut a, "component.update_links", json!({"components": [first, second]})); + assert_eq!((r["updated"].as_array().map(Vec::len), r["failed"].clone()), (Some(2), json!([])), "{r}"); + assert!((volume(&mut a) - 20000.0).abs() < 1e-6, "{}", volume(&mut a)); + run(&mut a, "edit.undo", json!({})); + assert!((volume(&mut a) - 14000.0).abs() < 1e-6, "{}", volume(&mut a)); + // A design it can't read keeps its bodies and is named; the others still come in. + std::fs::write(&block, b"not a design").unwrap(); + let r = run(&mut a, "component.update_links", json!({"components": [first, third]})); + assert_eq!(r["updated"].as_array().map(Vec::len), Some(1), "{r}"); + assert_eq!((r["failed"][0]["component"].clone(), r["failed"][0]["name"].clone()), (third.clone(), json!("block")), "{r}"); + assert!(r["warnings"].as_array().is_some_and(|w| w.iter().any(|w| w.as_str().is_some_and(|w| w.starts_with("block")))), "{r}"); + assert!((volume(&mut a) - 17000.0).abs() < 1e-6, "{}", volume(&mut a)); + // Not taken in, it still says its file changed (#9739). + let links = run(&mut a, "component.links", json!({})); + let block_link = links["links"].as_array().and_then(|l| l.iter().find(|l| l["component"] == third)).cloned(); + assert_eq!(block_link.map(|l| l["changed"].clone()), Some(json!(true)), "{links}"); + // None it can read: an error, and nothing to undo. + let err = a.execute("component.update_links", &json!({"components": [third]})).unwrap_err().to_string(); + assert!(err.contains("block"), "{err}"); + // Malformed lists are refused. + for bad in + [json!({"components": []}), json!({"components": 3}), json!({"components": [first], "component": first}), json!({"components": ["nope"]})] + { + assert!(a.execute("component.update_links", &bad).is_err(), "{bad}"); + } + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn inserting_a_missing_file_or_bad_parameters_changes_nothing() { let mut s = Session::default(); diff --git a/crates/engine/src/cmd/component_properties.rs b/crates/engine/src/cmd/component_properties.rs index 023f7b7d48..7ef57a14ee 100644 --- a/crates/engine/src/cmd/component_properties.rs +++ b/crates/engine/src/cmd/component_properties.rs @@ -47,8 +47,18 @@ fn placed_doc(s: &Session) -> Document { /// A property total must cover the entire assembly. The display traversal intentionally /// truncates hostile designs, so use an explicit error instead of accepting its prefix. pub(in crate::cmd) fn placements(doc: &Document, cmd: &str) -> Result, Mat)>> { - let mut out = vec![(0, Vec::new(), solidcraft_doc::IDENTITY)]; - let mut stack = vec![(0, Vec::::new(), solidcraft_doc::IDENTITY)]; + subtree(doc, (0, Vec::new(), solidcraft_doc::IDENTITY), true, cmd) +} + +/// `root` and, with `descendants`, every placement under it, each with its world transform. +/// The occurrences are grouped by parent once: searching all of them at every placement made a +/// large assembly's traversal quadratic. +fn subtree(doc: &Document, root: (u64, Vec, Mat), descendants: bool, cmd: &str) -> Result, Mat)>> { + let mut out = vec![root.clone()]; + if !descendants { + return Ok(out); + } + let mut stack = vec![root]; let mut children: std::collections::BTreeMap> = std::collections::BTreeMap::new(); for occurrence in &doc.occurrences { children.entry(occurrence.parent).or_default().push(occurrence); @@ -105,6 +115,95 @@ pub(super) fn occurrence(s: &mut Session, p: &Value) -> Result { Ok(out) } +/// The linear part of `m` is a rotation (no scale, shear or mirror), to rounding. +fn proper_rotation(m: &Mat) -> bool { + let column = |i: usize| m.get(i).map_or(Vec3::ZERO, |c| Vec3::new(c[0], c[1], c[2])); + let (x, y, z) = (column(0), column(1), column(2)); + m.iter().flatten().all(|v| v.is_finite()) + && [x, y, z].iter().all(|a| (a.dot(*a) - 1.0).abs() < 1e-9) + && x.dot(y).abs() < 1e-9 + && x.dot(z).abs() < 1e-9 + && y.dot(z).abs() < 1e-9 + && x.cross(y).dot(z) > 0.0 +} + +/// Every axis of `m` goes to an axis (a quarter turn, or none): a box turned so stays a box. +fn square_to_axes(m: &Mat) -> bool { + m.iter().take(3).all(|c| c.iter().take(3).filter(|v| v.abs() > 1e-12).count() == 1) +} + +/// The box around the corners of `b` placed by `m`. +fn placed_box(m: &Mat, b: &Aabb3) -> Aabb3 { + let mut out = Aabb3::EMPTY; + if b.is_empty() { + return out; + } + for i in 0..8 { + let pick = |bit: usize, lo: f64, hi: f64| if i & bit == 0 { lo } else { hi }; + out.add(solidcraft_doc::apply_point(m, Vec3::new(pick(1, b.min.x, b.max.x), pick(2, b.min.y, b.max.y), pick(4, b.min.z, b.max.z)))); + } + out +} + +/// A body's measurements where a placement puts it. +struct Placed { + volume: f64, + area: f64, + centroid: Vec3, + bbox: Aabb3, + closed: bool, + faces: usize, + edges: usize, + vertices: usize, +} + +/// `body` measured where `transform` places it. A rigid placement moves nothing but the centre +/// and the box: the body's own measurement (worked out once and shared by every placement of it, +/// [`solidcraft_doc::ModelBody::measure`]) is reused, where measuring each placed copy again took +/// three fine tessellations per copy and made Properties of a large assembly take minutes +/// (#8706). A placement that turns the body off the axes takes its box from the body's mesh +/// placed there (shared with the display). Any other placement (scaled or mirrored) is measured +/// as placed, as before. +fn measure_placed(body: &solidcraft_doc::ModelBody, transform: Mat) -> Result { + if proper_rotation(&transform) { + let m = body.measure()?; + let bbox = if square_to_axes(&transform) { + placed_box(&transform, &m.bbox) + } else { + let mesh = body.mesh(); + let mut b = Aabb3::EMPTY; + for p in &mesh.positions { + b.add(solidcraft_doc::apply_point(&transform, *p)); + } + if b.is_empty() { placed_box(&transform, &m.bbox) } else { b } + }; + return Ok(Placed { + volume: m.volume, + area: m.area, + centroid: solidcraft_doc::apply_point(&transform, m.centroid), + bbox, + closed: crate::cmd::inspect::encloses_volume(&body.body), + faces: m.merged.faces, + edges: m.merged.edges, + vertices: m.merged.vertices, + }); + } + #[cfg(test)] + work::count(|w| w.placed_measures += 1); + let placed = solidcraft_kernel::transform_matrix(&body.body, transform)?; + let m = solidcraft_kernel::measure(&placed)?; + Ok(Placed { + volume: m.volume, + area: m.area, + centroid: m.centroid, + bbox: m.bbox, + closed: crate::cmd::inspect::encloses_volume(&placed), + faces: m.merged.faces, + edges: m.merged.edges, + vertices: m.merged.vertices, + }) +} + fn properties(s: &Session, doc: &Document, selected: &[u64], descendants: bool, about: Option, cmd: &str) -> Result { let state = s.model.state(); let mut bodies = Vec::new(); @@ -115,48 +214,54 @@ fn properties(s: &Session, doc: &Document, selected: &[u64], descendants: bool, let mut placement_count = 0; let mut skipped = 0; let selected_placements = if selected.is_empty() { - if descendants { placements(doc, cmd)? } else { vec![(0, Vec::new(), solidcraft_doc::IDENTITY)] } + subtree(doc, (0, Vec::new(), solidcraft_doc::IDENTITY), descendants, cmd)? } else { let placement = s.occurrence_placement(selected).map_err(|e| bad(cmd, e.to_string()))?; - let mut out = vec![(placement.component, selected.to_vec(), placement.world)]; - let mut stack = if descendants { out.clone() } else { Vec::new() }; - while let Some((component, path, transform)) = stack.pop() { - for occurrence in doc.occurrences.iter().filter(|o| o.parent == component) { - if path.len() >= 64 || out.len() >= 100_000 { - return Err(bad(cmd, "assembly exceeds physical-properties traversal limits (64 levels or 100000 placements)")); - } - let mut child = path.clone(); - child.push(occurrence.id); - let world = solidcraft_doc::mat_mul(&transform, &occurrence.transform); - out.push((occurrence.component, child.clone(), world)); - stack.push((occurrence.component, child, world)); - } - } - out + subtree(doc, (placement.component, selected.to_vec(), placement.world), descendants, cmd)? }; + // Each component's bodies (with their index in the model), the timeline looked up once rather + // than for every body at every placement. + let mut by_component: std::collections::HashMap> = std::collections::HashMap::new(); + for (index, (body, component)) in state.bodies.iter().zip(doc.model_body_components(&state)).enumerate() { + by_component.entry(component).or_default().push((index, body)); + } // How each closed body's volume spreads about its centre of mass (#8095): one tessellation // however often it is placed, which each placement then turns. let components: BTreeSet = selected_placements.iter().map(|(component, _, _)| *component).collect(); - let spread_bodies: Vec = (0..state.bodies.len()) - .filter(|&i| { - state.bodies.get(i).is_some_and(|b| components.contains(&doc.model_body_component(b)) && crate::cmd::inspect::encloses_volume(&b.body)) - }) + let spread_bodies: Vec = components + .iter() + .filter_map(|c| by_component.get(c)) + .flatten() + .filter(|(_, b)| crate::cmd::inspect::encloses_volume(&b.body)) + .map(|(i, _)| *i) .collect(); + #[cfg(test)] + work::count(|w| w.spreads += spread_bodies.len()); let spreads = solidcraft_kernel::map_on_threads(&spread_bodies, 16, |&i| { state.bodies.get(i).and_then(|b| solidcraft_io::mass_properties(&b.body).ok().flatten()).and_then(|m| m.gyration()) }); let spread: BTreeMap = spread_bodies.into_iter().zip(spreads).filter_map(|(i, g)| Some((i, g.flatten()?))).collect(); + // A window waiting for the result (the Properties window) sees how far it is and can stop it. + let watch = solidcraft_kernel::watch::current(); + let total_bodies: usize = selected_placements.iter().map(|(c, _, _)| by_component.get(c).map_or(0, Vec::len)).sum(); + let mut done = 0; // Closed bodies' (mass, centre of mass, inertia about it), all in world axes. let mut parts: Vec<(f64, Vec3, Option)> = Vec::new(); for (component, path, transform) in selected_placements { placement_count += 1; - for (index, body) in state.bodies.iter().enumerate().filter(|(_, b)| doc.model_body_component(b) == component) { + for &(index, body) in by_component.get(&component).into_iter().flatten() { + if let Some(w) = &watch { + if w.cancelled() { + return Err(bad(cmd, "cancelled")); + } + w.progress("measuring", done, total_bodies); + } + done += 1; let transform: Mat = doc.model_body_offset(body).map(|offset| solidcraft_doc::mat_mul(&transform, offset)).unwrap_or(transform); - let placed = solidcraft_kernel::transform_matrix(&body.body, transform)?; - let measured = solidcraft_kernel::measure(&placed)?; + let measured = measure_placed(body, transform)?; let density = doc.density(&body.name); let body_mass = measured.volume * density / 1000.0; - let closed = crate::cmd::inspect::encloses_volume(&placed); + let closed = measured.closed; // The mesh's spread with the exact mass, so the tensor agrees with the mass shown. let body_inertia = spread .get(&index) @@ -173,9 +278,9 @@ fn properties(s: &Session, doc: &Document, selected: &[u64], descendants: bool, } area += measured.area; bounds = bounds.union(&measured.bbox); - faces += measured.merged.faces; - edges += measured.merged.edges; - vertices += measured.merged.vertices; + faces += measured.faces; + edges += measured.edges; + vertices += measured.vertices; bodies.push(json!({"name": body.name, "component": component, "path": path, "material": doc.body_material(&body.name), "closed": closed, "density_g_cm3": closed.then_some(density), @@ -185,6 +290,9 @@ fn properties(s: &Session, doc: &Document, selected: &[u64], descendants: bool, "bbox": {"min": measured.bbox.min, "max": measured.bbox.max}})); } } + if let Some(w) = &watch { + w.progress("measuring", done, total_bodies); + } if !(volume.is_finite() && area.is_finite() && mass.is_finite() && moment.is_finite()) { return Err(bad(cmd, "physical properties exceed supported numeric range")); } @@ -217,6 +325,46 @@ fn properties(s: &Session, doc: &Document, selected: &[u64], descendants: bool, Ok(out) } +/// The tessellating work a properties command did on this thread, for tests: placed copies +/// measured afresh (transformed, then tessellated) and bodies whose inertia spread was +/// tessellated. Counted where the work is asked for, on the command's own thread. +#[cfg(test)] +pub(super) mod work { + use std::cell::Cell; + + #[derive(Clone, Copy, Debug, Default, PartialEq)] + pub struct Work { + pub placed_measures: usize, + pub spreads: usize, + } + + thread_local! { + static WORK: Cell = const { Cell::new(Work { placed_measures: 0, spreads: 0 }) }; + } + + pub(super) fn count(f: impl FnOnce(&mut Work)) { + WORK.with(|w| { + let mut work = w.get(); + f(&mut work); + w.set(work); + }); + } + + /// `f`'s result and the work it did. + pub fn capture(f: impl FnOnce() -> T) -> (T, Work) { + let before = WORK.with(Cell::get); + let out = f(); + let after = WORK.with(Cell::get); + ( + out, + Work { + placed_measures: after.placed_measures.saturating_sub(before.placed_measures), + spreads: after.spreads.saturating_sub(before.spreads), + }, + ) + } +} + #[cfg(test)] #[path = "component_properties_tests.rs"] mod tests; diff --git a/crates/engine/src/cmd/component_properties_tests.rs b/crates/engine/src/cmd/component_properties_tests.rs index f291014ea3..20fcaee26e 100644 --- a/crates/engine/src/cmd/component_properties_tests.rs +++ b/crates/engine/src/cmd/component_properties_tests.rs @@ -422,3 +422,82 @@ fn configurations_switch_to_a_user_defined_material() { run(&mut s, "material.delete", json!({"material": "Black Walnut", "replace": "Default"})); assert!(!s.doc.materials.contains_key("Slat")); } + +/// #8706: Properties of a large assembly took minutes because every placed copy of a body was +/// transformed and measured again (three fine tessellations each). Every placement of a body now +/// reuses one measurement of it: 41 placements measure no placed copy afresh and tessellate +/// each distinct body's inertia spread (#8095) once, exactly as one placement does, and the +/// answer is the same when measured again. +#[test] +fn repeated_placements_reuse_one_measurement_of_their_body() { + let mut s = Session::default(); + let (component, _, _) = box_in(&mut s, "Block"); + run(&mut s, "solid.cylinder", json!({"diameter": 4, "height": 30})); + for i in 1..40 { + run(&mut s, "occurrence.copy", json!({"component": component, "translate": [20 * i, 0, 0]})); + } + let turned = run(&mut s, "occurrence.copy", json!({"component": component, "translate": [0, 50, 0]})); + run(&mut s, "occurrence.move", json!({"occurrence": turned["occurrence"], "translate": [0, 0, 0], "axis": [1, 1, 1], "angle": "30 deg"})); + let first = run(&mut s, "component.properties", json!({"component": "root"})); + assert_eq!(first["body_count"], 82, "{first}"); + let (again, all) = super::work::capture(|| run(&mut s, "component.properties", json!({"component": "root"}))); + assert_eq!(first, again); + let (block, one) = super::work::capture(|| run(&mut s, "occurrence.properties", json!({"occurrence": turned["occurrence"]}))); + // Before, every placed copy was transformed and tessellated afresh (82 here); now no copy + // is, and the work is the same for 41 placements as for one: one spread per distinct body. + let per_body = super::work::Work { placed_measures: 0, spreads: 2 }; + assert_eq!(all, per_body, "41 placements"); + assert_eq!(one, per_body, "one placement"); + close(&first["total"]["volume_mm3"], 41.0 * block["total"]["volume_mm3"].as_f64().unwrap()); +} + +/// #8706: a body measured where a rigid placement puts it, from its own measurement, matches its +/// placed copy measured afresh: volume, area, centre and counts, and the box (to the chord of +/// the meshes it is taken from) also when the placement turns it off the axes. +#[test] +fn a_placed_body_measures_as_its_placed_copy() { + use solidcraft_geom::Vec3; + let mut s = Session::default(); + run(&mut s, "solid.cylinder", json!({"diameter": 4, "height": 30})); + run(&mut s, "solid.box", json!({"length": 10, "width": 20, "height": 5})); + let state = s.model.state(); + let placements = [ + solidcraft_doc::IDENTITY, + solidcraft_doc::rigid(Vec3::new(5.0, -3.0, 7.0), Vec3::ZERO, Vec3::Z, std::f64::consts::FRAC_PI_2), + solidcraft_doc::rigid(Vec3::new(100.0, 0.0, 0.0), Vec3::new(1.0, 2.0, 3.0), Vec3::new(1.0, 1.0, 1.0), 0.5), + ]; + for body in &state.bodies { + for m in placements { + let fast = super::measure_placed(body, m).unwrap(); + let placed = solidcraft_kernel::transform_matrix(&body.body, m).unwrap(); + let slow = solidcraft_kernel::measure(&placed).unwrap(); + let near = |a: f64, b: f64, tol: f64| assert!((a - b).abs() <= tol * b.abs().max(1.0), "{} {a} != {b}", body.name); + near(fast.volume, slow.volume, 1e-4); + near(fast.area, slow.area, 1e-4); + assert!((fast.centroid - slow.centroid).len() < 1e-3, "{} centre {:?} != {:?}", body.name, fast.centroid, slow.centroid); + let box_near = (fast.bbox.min - slow.bbox.min).len() < 0.05 && (fast.bbox.max - slow.bbox.max).len() < 0.05; + assert!(box_near, "{} box {:?} != {:?}", body.name, fast.bbox, slow.bbox); + assert_eq!((fast.faces, fast.edges, fast.vertices), (slow.merged.faces, slow.merged.edges, slow.merged.vertices)); + assert_eq!(fast.closed, crate::cmd::inspect::encloses_volume(&placed)); + } + } +} + +/// #8706: a window waiting for the properties sees how many bodies are measured and can stop the +/// measuring part way. +#[test] +fn a_watcher_sees_the_bodies_measured_and_can_stop_them() { + use std::sync::Arc; + let mut s = Session::default(); + let (component, _, _) = box_in(&mut s, "Block"); + for i in 1..4 { + run(&mut s, "occurrence.copy", json!({"component": component, "translate": [20 * i, 0, 0]})); + } + let progress = Arc::new(solidcraft_doc::progress::Progress::default()); + let r = solidcraft_doc::progress::watch(progress.clone(), || s.execute("component.properties", &json!({"component": "root"}))); + assert_eq!(r.unwrap()["body_count"], 4); + assert_eq!(progress.work(), Some(("measuring", 4, 4))); + progress.cancel(); + let stopped = solidcraft_doc::progress::watch(progress, || s.execute("component.properties", &json!({"component": "root"}))); + assert!(stopped.unwrap_err().to_string().contains("cancelled")); +} diff --git a/crates/engine/src/cmd/delete.rs b/crates/engine/src/cmd/delete.rs index a867786f4f..36e3a0a268 100644 --- a/crates/engine/src/cmd/delete.rs +++ b/crates/engine/src/cmd/delete.rs @@ -36,6 +36,9 @@ fn keys(p: &Value, k: &str) -> Vec { fn delete(s: &mut Session, p: &Value) -> Result { if bool_(p, "dry_run").unwrap_or(false) { let mut t = s.scratch(); + // What the check computes is kept where this session keeps it (a check run ahead, off a + // front end's window thread), so the delete itself finds it there (#9917). + t.model.keep(s.model.kept()); return apply(&mut t, p); } let r = apply(s, p)?; @@ -160,8 +163,7 @@ fn apply(s: &mut Session, p: &Value) -> Result { done = true; } if !features.is_empty() { - let ids: Vec = features.iter().map(u64::to_string).collect(); - sub(s, "timeline.delete", json!({ "features": ids }))?; + sub(s, "timeline.delete", json!({ "features": features }))?; done = true; } // Bodies still there (not made by a feature deleted above) get a Remove feature. @@ -337,6 +339,69 @@ mod tests { assert!(s.model.state().sketch(sk).unwrap().sketch.constraints.iter().all(|c| c.id != angle)); } + /// #9917: the check before a delete in a sketch with a rolled-back history after it builds the + /// history before the delete only when a feature fails after it. Deleting a hole in the plate + /// changes the plate and every cut after it and breaks none of them: the check reports + /// nothing. Deleting a side of the plate's outline breaks the plate, and the check still says + /// so, with the cache too small to keep a state (as on a large design). + #[test] + fn a_delete_in_a_rolled_back_history_reports_what_breaks_after_the_marker_and_nothing_else() { + let mut s = Session::default(); + let sk = run(&mut s, "sketch.create", json!({"plane": "XY"}))["sketch"].as_u64().unwrap(); + let outline = run(&mut s, "sketch.line", json!({"points": [[0, 0], [40, 0], [30, 20], [10, 20]], "closed": true}))["curves"][0] + .as_str() + .unwrap() + .to_string(); + let hole = run(&mut s, "sketch.circle.center", json!({"center": [30, 15], "radius": 2}))["curves"][0].as_str().unwrap().to_string(); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"distance": 5, "name": "Plate"})); + for i in 0..6 { + run(&mut s, "solid.cylinder", json!({"base": [12 + 3 * i, 8, -1], "radius": 1, "height": 10, "operation": "cut"})); + } + run(&mut s, "timeline.roll_to", json!({"position": 1})); + run(&mut s, "sketch.edit", json!({"sketch": sk})); + *s.model.cache.lock().unwrap() = solidcraft_doc::EvalCache::with_budget(1); + let doc = s.doc.clone(); + let dry = run(&mut s, "selection.delete", json!({"items": [{"type": "sketch_curve", "id": hole}], "dry_run": true})); + assert_eq!(dry["would_fail"], json!([]), "{dry}"); + let dry = run(&mut s, "selection.delete", json!({"items": [{"type": "sketch_curve", "id": outline}], "dry_run": true})); + assert!(dry["would_fail"].as_array().unwrap().contains(&json!("Plate")), "{dry}"); + assert_eq!(*s.doc, *doc, "the checks change nothing"); + } + + /// #9917: a delete checked ahead (off a front end's window thread) keeps what its dry run + /// builds of the history after the marker, and the delete run with that finds all of it: + /// nothing is built again on the window thread, where it took half a minute. + #[test] + fn a_delete_checked_ahead_keeps_the_history_it_builds_for_the_delete() { + let mut s = Session::default(); + let sk = run(&mut s, "sketch.create", json!({"plane": "XY"}))["sketch"].as_u64().unwrap(); + run(&mut s, "sketch.line", json!({"points": [[0, 0], [40, 0], [30, 20], [10, 20]], "closed": true})); + let hole = run(&mut s, "sketch.circle.center", json!({"center": [30, 15], "radius": 2}))["curves"][0].as_str().unwrap().to_string(); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"distance": 5, "name": "Plate"})); + let later = 6; + for i in 0..later { + run(&mut s, "solid.cylinder", json!({"base": [12 + 3 * i, 8, -1], "radius": 1, "height": 10, "operation": "cut"})); + } + run(&mut s, "timeline.roll_to", json!({"position": 1})); + run(&mut s, "sketch.edit", json!({"sketch": sk})); + // A cache too small to keep a state, as on a large design: only what is kept carries over. + *s.model.cache.lock().unwrap() = solidcraft_doc::EvalCache::with_budget(1); + let items = json!([{"type": "sketch_curve", "id": hole}]); + let ahead = s.ahead(); + let kept = ahead.kept(); + let replies = ahead.replies(&[("selection.delete".to_string(), json!({"items": items, "dry_run": true}))]); + assert!(replies.iter().all(Result::is_ok), "{replies:?}"); + let built = kept.len(); + assert!(built > later, "the check kept the history after the marker: {built} results"); + s.model.keep(Some(kept.clone())); + run(&mut s, "selection.delete", json!({"items": items})); + s.model.keep(None); + assert_eq!(kept.len(), built, "the delete found everything it builds among what the check kept"); + assert!(s.model.state().sketch(sk).unwrap().sketch.curves.iter().all(|c| c.id != hole)); + } + #[test] fn mixed_selection_is_one_undo_step() { let (mut s, _) = part(); diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index f49e881d4f..f17b911e69 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -6,7 +6,7 @@ use serde_json::{Value, json}; use solidcraft_doc::{Feature, FeatureKind}; use super::CommandSpec; -use crate::params::{bad, bool_, name_or_id_list, num, str_}; +use crate::params::{bad, bool_, name_or_id_values, num, str_}; use crate::{EngineError, Result, Sel, Session, Snapshot}; pub static COMMANDS: &[CommandSpec] = &[ @@ -23,11 +23,11 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("edit.checkpoint", "Checkpoint", checkpoint).icon("checkpoint") .noundo() .doc_only() - .params("→ {checkpoint: id}: remember the design, active sketch and undo history (restore with edit.restore_checkpoint)"), + .params("→ {checkpoint: id}: remember the design, active sketch and undo history (restore with edit.restore_checkpoint; the last 16 are kept, the oldest dropped first)"), CommandSpec::new("edit.restore_checkpoint", "Restore Checkpoint", restore_checkpoint).icon("restore") .noundo() .doc_only() - .params("checkpoint: id; forget?: bool (true = drop it without going back). Goes back to it, undo history included"), + .params("checkpoint: id; forget?: bool (true = drop it without going back). Goes back to it, undo history included. Restoring or forgetting uses it and every later checkpoint up: take a new one to go back again"), CommandSpec::new("timeline.compute_all", "Compute All", compute_all).at("SOLID", "MODIFY").icon("compute").noundo(), CommandSpec::new("timeline.play", "Play History", crate::checked!("timeline.play", play)) .at("INSPECT", "HISTORY") @@ -88,7 +88,7 @@ pub static COMMANDS: &[CommandSpec] = &[ ), CommandSpec::new("select.set", "Select", select_set).icon("select") .noundo() - .params("items: [{type: body|edge|face|feature|sketch_curve|sketch_point|profile, …}], add?: bool"), + .params("items: [{type: body|edge|face|feature|sketch_curve|sketch_point|profile, …}], add?: bool; face/edge: body plus point [x,y,z] in world mm or index (both also accepted); a point uses Measure's pick tolerance, an index supplies a point on the item; invalid partial picks leave the selection unchanged"), CommandSpec::new("select.filter", "Selection filter", select_filter).icon("select").noundo().doc_only() .params("mode: body|face|component → validated UI pick granularity; headless echo only, no document edits or undo"), CommandSpec::new("select.clear", "Clear Selection", select_clear).icon("select").noundo(), @@ -179,47 +179,37 @@ fn compute_all(s: &mut Session, _p: &Value) -> Result { Ok(json!({"features": s.model.results.len(), "errors": errors})) } -fn feature_id(s: &Session, v: Option<&Value>, cmd: &str) -> Result { - let key = match v { - Some(Value::Number(n)) => n.to_string(), - Some(Value::String(x)) => x.clone(), - _ => return Err(bad(cmd, "`feature` must be an id or name")), - }; - s.doc.find_feature(&key).map(|f| f.id).ok_or_else(|| bad(cmd, format!("no feature `{key}`"))) +pub(super) fn feature_id(s: &Session, v: Option<&Value>, cmd: &str) -> Result { + match v { + Some(Value::Number(n)) => n.as_u64().and_then(|id| s.doc.feature(id)).map(|f| f.id).ok_or_else(|| bad(cmd, format!("no feature `{n}`"))), + Some(Value::String(name)) => s.doc.resolve_feature_name(name).map(|f| f.id).map_err(|e| bad(cmd, e.to_string())), + _ => Err(bad(cmd, "`feature` must be an id or name")), + } } fn delete(s: &mut Session, p: &Value) -> Result { let cmd = "timeline.delete"; - // The features are named or numbered, and both kinds can sit in one list. - let mut keys = name_or_id_list(cmd, p, "features")?; - if let Some(f) = p.get("feature") { - keys.push(match f { - Value::Number(n) => n.to_string(), - Value::String(x) => x.clone(), - _ => String::new(), - }); - } + // Resolve every key against the same timeline. A cascade or a repeated key must never + // redirect a later delete to another feature that happens to match after the first one. + let keys = name_or_id_values(cmd, p, "features")?; + let mut ids = keys.iter().chain(p.get("feature")).map(|v| feature_id(s, Some(v), cmd)).collect::>>()?; let groups = group_ids(s, p, false, cmd)?; - if keys.is_empty() && groups.is_empty() { + if ids.is_empty() && groups.is_empty() { return Err(bad(cmd, "`features` must list features, or `groups` timeline folders")); } // A folder goes with its features and nested folders, its features in timeline order. let (folders, contents) = group_tree(&s.doc, &groups); let joints = group_joint_ids(&s.doc, &groups); - keys.extend(s.doc.features.iter().filter(|f| contents.contains(&f.id)).map(|f| f.id.to_string())); + ids.extend(s.doc.features.iter().filter(|f| contents.contains(&f.id)).map(|f| f.id)); // Features that work now; any of them failing after the delete is reported (a later // feature that names a deleted body, #267). let worked: std::collections::BTreeSet = s.model.results.iter().filter(|r| r.error.is_none() && !r.skipped).map(|r| r.id).collect(); super::joints::delete_joints(s.doc_mut(), &joints); let mut gone = Vec::new(); - for k in keys { - let Some(id) = s.doc.find_feature(&k).map(|f| f.id) else { - if gone.is_empty() { - return Err(bad(cmd, format!("no feature `{k}`"))); - } - continue; - }; - gone.extend(s.doc_mut().delete_feature(id)?); + for id in ids { + if s.doc.feature(id).is_some() { + gone.extend(s.doc_mut().delete_feature(id)?); + } } if !folders.is_empty() { s.doc_mut().timeline_groups.retain(|group| !folders.contains(&group.id)); @@ -1235,6 +1225,65 @@ fn edit_feature(s: &mut Session, p: &Value) -> Result { Ok(json!({"feature": id, "recomputed": s.model.last_recomputed, "restored": s.model.last_restored})) } +/// Complete a partial script pick without changing the stored selection type or reinterpreting +/// an existing index-and-point pair. Unlike Measure, selection keeps the exact face/edge index: +/// it must not merge a cylinder's patches or a circular rim's arcs into a different item. +fn selection_item(s: &Session, value: Value) -> Result { + use solidcraft_geom::Vec3; + + let kind = value.get("type").and_then(Value::as_str); + if !matches!(kind, Some("face" | "edge")) || (value.get("index").is_some() && value.get("point").is_some()) { + return serde_json::from_value(value).map_err(|e| bad("select.set", e.to_string())); + } + let face = kind == Some("face"); + #[derive(serde::Deserialize)] + struct Pick { + body: String, + index: Option, + point: Option, + } + let Pick { body, index, point } = serde_json::from_value(value).map_err(|e| bad("select.set", e.to_string()))?; + let state = s.world_state(); + let b = state.body(&body).ok_or_else(|| bad("select.set", format!("no body `{body}`")))?; + let missing = || bad("select.set", format!("no {} of `{body}` at the given index or point", if face { "face" } else { "edge" })); + let (index, point) = match (index, point) { + (Some(index), None) => { + let point = if face { + solidcraft_doc::naming::face_spot(b, index).map(|spot| spot.point) + } else { + let mesh = b.mesh(); + mesh.edges.get(index).and_then(|edge| match edge.as_slice() { + [a, b] => Some(a.lerp(*b, 0.5)), + // Use a sample on the curve, not the midpoint of a chord inside it. + points if points.len() > 2 => points.get(points.len() / 2).copied(), + _ => None, + }) + }; + (index, point.filter(|p| p.is_finite()).ok_or_else(missing)?) + } + (None, Some(point)) if point.is_finite() => { + let index = if face { + solidcraft_doc::appearance::face_index_at(b, point) + } else { + let mesh = b.mesh(); + mesh.edges + .iter() + .enumerate() + .filter_map(|(index, edge)| { + let distance = + edge.windows(2).filter_map(|w| Some(point.dist_to_segment(*w.first()?, *w.get(1)?))).fold(f64::INFINITY, f64::min); + (distance <= solidcraft_doc::appearance::pick_tolerance(b)).then_some((index, distance)) + }) + .min_by(|(_, a), (_, b)| a.total_cmp(b)) + .map(|(index, _)| index) + }; + (index.ok_or_else(missing)?, point) + } + _ => return Err(bad("select.set", "a face or edge needs an index or a finite point")), + }; + Ok(if face { Sel::Face { body, index, point } } else { Sel::Edge { body, index, point } }) +} + fn select_set(s: &mut Session, p: &Value) -> Result { let items = p.get("items").and_then(Value::as_array).cloned().unwrap_or_default(); if items.len() > 10_000 { @@ -1242,9 +1291,20 @@ fn select_set(s: &mut Session, p: &Value) -> Result { } let mut sel: Vec = Vec::new(); for it in items { - let one: Sel = serde_json::from_value(it).map_err(|e| bad("select.set", e.to_string()))?; - if let Sel::Occurrence { path } = &one { - s.occurrence_bodies(path, true).map_err(|e| bad("select.set", e.to_string()))?; + let one = selection_item(s, it)?; + match &one { + Sel::Occurrence { path } => { + s.occurrence_bodies(path, true).map_err(|e| bad("select.set", e.to_string()))?; + } + // A selection may not name a body that isn't there: left selected it would delete + // nothing, or the next body to take that name (#9737). + other => { + if let Some(name) = other.body_name() + && s.world_state().body(name).is_none() + { + return Err(bad("select.set", format!("no body `{name}`"))); + } + } } sel.push(one); } @@ -1348,11 +1408,21 @@ pub(super) fn newly_broken_in_history( let whole = |doc: &solidcraft_doc::Document, model: &solidcraft_doc::Model| { let mut doc = doc.clone(); doc.marker = None; - let mut model = model.clone(); - model.evaluate(&doc); - model + // A copy keeps nothing on its own: it keeps what the model keeps, so a delete checked + // ahead finds this build there when it runs (#9917). + let mut copy = model.clone(); + copy.keep(model.kept()); + copy.evaluate(&doc); + copy }; - newly_broken(&whole(before_doc, before), &whole(after_doc, after)) + // Only a feature that fails after the change can be newly broken: when none does, the + // history before it needn't be built again (#9917: each build ran every boolean after the + // marker, half a minute on a large design). + let after = whole(after_doc, after); + if after.results.iter().all(|r| r.error.is_none()) { + return Vec::new(); + } + newly_broken(&whole(before_doc, before), &after) } /// Whether `after_doc` differs from `before_doc` only in sketch constraints and dimensions (and diff --git a/crates/engine/src/cmd/edit_tests.rs b/crates/engine/src/cmd/edit_tests.rs index bbab9166e6..0cd451e370 100644 --- a/crates/engine/src/cmd/edit_tests.rs +++ b/crates/engine/src/cmd/edit_tests.rs @@ -8,6 +8,107 @@ fn run(s: &mut Session, id: &str, params: Value) -> Value { s.execute(id, ¶ms).unwrap_or_else(|e| panic!("{id} {params}: {e}")) } +#[test] +fn select_geometry_resolves_points_and_indices_without_changing_history() { + use solidcraft_geom::Vec3; + + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"body_name":"Block", "corner":[20,30,40], "length":10, "width":10, "height":10})); + let before = (s.doc.to_json(), s.undo.len()); + for (kind, point) in [("face", Vec3::new(25.0, 35.0, 50.0)), ("edge", Vec3::new(25.0, 30.0, 50.0))] { + assert_eq!(run(&mut s, "select.set", json!({"items":[{"type":kind,"body":"Block","point":point}]}))["selected"], 1); + let index = match &s.selection[0] { + Sel::Face { index, point: actual, .. } | Sel::Edge { index, point: actual, .. } => { + assert_eq!(*actual, point); + *index + } + other => panic!("unexpected selection: {other:?}"), + }; + let measured = s.measure_items(&s.selection); + let (field, expected) = if kind == "face" { ("area_mm2", 100.0) } else { ("length_mm", 10.0) }; + assert!((measured["items"][0][field].as_f64().unwrap() - expected).abs() < 1e-9, "{measured}"); + run(&mut s, "select.set", json!({"items":[{"type":kind,"body":"Block","index":index}]})); + let complete = serde_json::to_value(&s.selection[0]).unwrap(); + assert_eq!(complete["index"], index); + let mut point_only = complete.clone(); + point_only.as_object_mut().unwrap().remove("index"); + run(&mut s, "select.set", json!({"items":[point_only]})); + assert_eq!(serde_json::to_value(&s.selection[0]).unwrap(), complete); + } + assert_eq!((s.doc.to_json(), s.undo.len()), before); +} + +#[test] +fn select_geometry_curved_picks_round_trip_and_add_without_duplicates() { + let mut s = Session::default(); + run(&mut s, "solid.cylinder", json!({"body_name":"Round", "radius":5, "height":10})); + for (kind, point) in [("face", [3, 4, 5]), ("edge", [3, 4, 10])] { + run(&mut s, "select.set", json!({"items":[{"type":kind,"body":"Round","point":point}]})); + let index = serde_json::to_value(&s.selection[0]).unwrap()["index"].clone(); + run(&mut s, "select.set", json!({"items":[{"type":kind,"body":"Round","index":index}]})); + let complete = serde_json::to_value(&s.selection[0]).unwrap(); + let pick: solidcraft_geom::Vec3 = serde_json::from_value(complete["point"].clone()).unwrap(); + assert!((pick.x.hypot(pick.y) - 5.0).abs() < 1e-6, "the inferred point lies on the cylinder: {complete}"); + let mut partial = complete.clone(); + partial.as_object_mut().unwrap().remove("index"); + run(&mut s, "select.set", json!({"items":[partial, complete],"add":true})); + assert_eq!(s.selection.len(), 1); + assert_eq!(serde_json::to_value(&s.selection[0]).unwrap(), complete); + let measured = s.measure_items(&s.selection); + assert!((measured["items"][0]["diameter"].as_f64().unwrap() - 10.0).abs() < 1e-6, "{measured}"); + } +} + +#[test] +fn select_geometry_uses_the_placed_body_in_world_coordinates() { + let mut s = Session::default(); + let created = run(&mut s, "component.create", json!({"name":"Part"})); + run(&mut s, "solid.box", json!({"body_name":"Block", "length":2, "width":3, "height":4})); + let second = run(&mut s, "occurrence.copy", json!({"occurrence":created["occurrence"]}))["occurrence"].as_u64().unwrap(); + run(&mut s, "occurrence.move", json!({"occurrence":second,"translate":[10,20,0],"axis":[0,0,1],"angle":"90 deg","capture":false})); + let name = s.occurrence_placement(&[second]).unwrap().bodies[0].world.clone(); + for (kind, point) in [("face", [8.5, 21.0, 4.0]), ("edge", [8.5, 20.0, 4.0])] { + run(&mut s, "select.set", json!({"items":[{"type":kind,"body":name,"point":point}]})); + let complete = serde_json::to_value(&s.selection[0]).unwrap(); + assert_eq!(complete["body"], name); + run(&mut s, "select.set", json!({"items":[{"type":kind,"body":name,"index":complete["index"]}]})); + let inferred = serde_json::to_value(&s.selection[0]).unwrap(); + let pick: solidcraft_geom::Vec3 = serde_json::from_value(inferred["point"].clone()).unwrap(); + assert!((pick.z - 4.0).abs() < 1e-9 && (7.0..=10.0).contains(&pick.x) && (20.0..=22.0).contains(&pick.y), "{inferred}"); + assert!(s.execute("select.set", &json!({"items":[{"type":kind,"body":name,"point":[1,1,4]}]})).is_err()); + assert_eq!(serde_json::to_value(&s.selection[0]).unwrap(), inferred); + } +} + +#[test] +fn select_geometry_invalid_partial_picks_leave_selection_and_revision_unchanged() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"body_name":"Block", "length":10, "width":10, "height":10})); + run(&mut s, "select.set", json!({"items":[{"type":"body","name":"Block"}]})); + let before = (s.selection.clone(), s.revision, s.undo.len()); + for kind in ["face", "edge"] { + for fields in [ + json!({}), + json!({"index":999999}), + json!({"index":-1}), + json!({"index":"0"}), + json!({"point":[500,500,500]}), + json!({"point":[1,2]}), + json!({"point":[1e308,1e308,1e308]}), + ] { + let mut item = json!({"type":kind,"body":"Block"}); + item.as_object_mut().unwrap().extend(fields.as_object().unwrap().clone()); + for add in [false, true] { + let result = s.execute("select.set", &json!({"items":[{"type":"face","body":"Block","point":[5,5,10]},item],"add":add})); + assert!(result.is_err(), "accepted {item}"); + assert_eq!((s.selection.clone(), s.revision, s.undo.len()), before, "{item}"); + } + } + } + assert!(s.execute("select.set", &json!({"items":[{"type":"face","body":"Missing","point":[5,5,10]}]})).is_err()); + assert_eq!((s.selection, s.revision, s.undo.len()), before); +} + #[test] fn history_playback_is_a_typed_display_request_without_history_edits() { let mut s = Session::default(); @@ -774,6 +875,45 @@ fn feature_names(s: &Session) -> Vec { s.doc.features.iter().map(|feature| feature.name.clone()).collect() } +#[test] +fn timeline_keys_distinguish_numeric_names_from_ids() { + let mut s = four_boxes(); + let id = s.doc.find_feature("Second").unwrap().id; + let named = s.doc.find_feature("Third").unwrap().id; + let key = id.to_string(); + run(&mut s, "timeline.rename", json!({"feature": named, "name": key})); + + assert_eq!(run(&mut s, "timeline.inspect", json!({"feature": key}))["id"], named); + assert_eq!(run(&mut s, "timeline.inspect", json!({"feature": id}))["id"], id); + assert_eq!(run(&mut s, "timeline.inspect", json!({"feature": named.to_string()}))["id"], named); + assert_eq!(run(&mut s, "timeline.copy", json!({"features": [key, id]}))["copied"], json!(["Second", key])); + + // Resolve the whole request before deleting: a repeated name must not turn into an id + // after the first deletion removes the feature with that name. + assert_eq!(run(&mut s, "timeline.delete", json!({"features": [key, key]}))["deleted"], json!([named])); + assert!(s.doc.feature(id).is_some()); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "selection.delete", json!({"items": [Sel::Feature { id }]})); + assert!(s.doc.feature(id).is_none()); + assert!(s.doc.feature(named).is_some()); +} + +#[test] +fn timeline_keys_prefer_exact_case_and_refuse_an_ambiguous_fallback() { + let mut s = four_boxes(); + let second = s.doc.find_feature("Second").unwrap().id; + let third = s.doc.find_feature("Third").unwrap().id; + run(&mut s, "timeline.rename", json!({"feature": third, "name": "second"})); + + let before = s.doc.clone(); + let error = s.execute("timeline.delete", &json!({"features": ["First", "SECOND"]})).unwrap_err().to_string(); + assert!(error.contains("ambiguous"), "{error}"); + assert_eq!(s.doc, before); + assert_eq!(run(&mut s, "timeline.inspect", json!({"feature": "Second"}))["id"], second); + assert_eq!(run(&mut s, "timeline.suppress", json!({"feature": "second"}))["feature"], third); + assert_eq!(run(&mut s, "timeline.inspect", json!({"feature": "FIRST"}))["name"], "First"); +} + /// `timeline.delete {groups}` deletes a folder with its features and nested folders in one undo /// step; other folders and features stay, and `timeline.ungroup` still keeps the features (#7219). #[test] @@ -1069,6 +1209,57 @@ fn counted_body_names_stay_when_the_timeline_changes() { assert_eq!(bodies(&s), named(&[("Body2", "B"), ("Body3", "C"), ("Body4", "E")])); } +/// Pinning reads each feature's bodies once and keeps them with the state it read them from; +/// the names it pins and reserves, and the fingerprints it leaves, are those of reading every +/// feature's bodies again: random boxes (named and counted), moves, suppressions, deletions, +/// reorders, undo and redo, against a session that forgets what it read before every command and +/// against fingerprinting the whole timeline again. +#[test] +fn pinning_with_the_bodies_it_read_before_pins_as_reading_them_again() { + let (mut kept, mut fresh) = (Session::default(), Session::default()); + let mut seed = 0x9e37_79b9_7f4a_7c15u64; + let mut next = |n: usize| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + (seed % n as u64) as usize + }; + for step in 0..60 { + let features: Vec = kept.doc.features.iter().map(|f| f.name.clone()).collect(); + let counted: Vec = kept.model.state().bodies.iter().map(|b| b.name.clone()).collect(); + let pick = |names: &[String], k: usize| names.get(k % names.len().max(1)).cloned().unwrap_or_default(); + let x = 15 * step; + let (id, params) = match next(9) { + _ if features.len() < 3 => ("solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0]})), + 0 | 1 => ("solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0]})), + 2 => ("solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0], "body_name": format!("Named {step}")})), + 3 => ("solid.move", json!({"bodies": [pick(&counted, next(64))], "translate": [0, 5, 0]})), + 4 => ("timeline.suppress", json!({"feature": pick(&features, next(64)), "suppressed": next(2) == 0})), + 5 => ("timeline.delete", json!({"feature": pick(&features, next(64))})), + 6 => ("timeline.reorder", json!({"feature": pick(&features, next(64)), "position": next(features.len()), "active": true})), + 7 => ("edit.undo", json!({})), + _ => ("edit.redo", json!({})), + }; + fresh.made_bodies.clear(); + let (a, b) = (kept.execute(id, ¶ms), fresh.execute(id, ¶ms)); + assert_eq!(a.is_ok(), b.is_ok(), "step {step}: {id} {params}"); + assert_eq!(kept.doc.to_json(), fresh.doc.to_json(), "step {step}: {id} {params}"); + // The fingerprints match the design as fingerprinting the whole timeline again makes them + // match it: evaluating it again reuses the same results (all, as a rule; the others are + // computed or taken from the cache the copies share). + let again = |model: &solidcraft_doc::Model| { + let mut m = model.clone(); + m.evaluate(&kept.doc); + m.last_recomputed + m.last_restored + }; + let mut full = kept.model.clone(); + full.refingerprint(&kept.doc); + assert_eq!(again(&kept.model), again(&full), "step {step}: {id} {params}"); + assert_eq!(again(&kept.model), again(&fresh.model), "step {step}: {id} {params}"); + } + assert!(kept.doc.features.len() > 5 && !kept.doc.reserved_body_names.is_empty()); +} + /// The value at a dotted path of a definition. fn dotted<'a>(def: &'a Value, path: &str) -> &'a Value { path.split('.').fold(def, |v, k| &v[k]) diff --git a/crates/engine/src/cmd/features_more.rs b/crates/engine/src/cmd/features_more.rs index 77b80fe35e..5446bf5706 100644 --- a/crates/engine/src/cmd/features_more.rs +++ b/crates/engine/src/cmd/features_more.rs @@ -43,7 +43,7 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("solid.align", "Align", align) .at("SOLID", "MODIFY") .icon("align") - .params("bodies: [names]; from: [x,y,z] | \"origin\" | {snap: vertex|edge_mid|face_center|circle_center, at: [x,y,z]} | from_face: [x,y,z] | from_plane: XY|XZ|YZ|construction or offset plane name or id|{origin, normal}; to: (the same) | to_face: [x,y,z] | to_plane: (a plane as from_plane; the point or face goes onto it along its normal, and the plane may move later: the bodies follow); faces and planes also turn to meet; flip?: bool"), + .params("bodies: [names]; from: [x,y,z] | \"origin\" | {snap: vertex|edge_mid|face_center|circle_center, at: [x,y,z]} | from_face: [x,y,z] | from_plane: XY|XZ|YZ|construction or offset plane name or id|{origin, normal}; to: (the same) | to_face: [x,y,z] | to_plane: (a plane as from_plane; the point or face goes onto it along its normal, and the plane may move later: the bodies follow); faces and planes also turn to meet; flip?: bool; axes?: [x, y, z] (point-to-point only: which world axes the offset moves along; default all three, at least one true)"), CommandSpec::new("solid.remove", "Remove", remove).at("SOLID", "MODIFY").icon("delete").params("bodies: [names] (removed from here on in the timeline)"), ]; @@ -398,6 +398,21 @@ fn replace_face(s: &mut Session, p: &Value) -> Result { add_feature(s, p, FeatureKind::ReplaceFace { faces: faces.into_iter().map(FacePick).collect(), target, body: body.map(BodyName) }) } +/// `axes`: three booleans, one per world axis, for a point-to-point Align. +/// `None` when the call does not restrict the move. +fn axes_param(p: &Value, cmd: &str) -> Result> { + let Some(v) = p.get("axes").filter(|v| !v.is_null()) else { return Ok(None) }; + let a = v.as_array().filter(|a| a.len() == 3).ok_or_else(|| bad(cmd, "`axes` must be [x, y, z] booleans"))?; + let mut mask = [false; 3]; + for (slot, v) in mask.iter_mut().zip(a) { + *slot = v.as_bool().ok_or_else(|| bad(cmd, "`axes` must be [x, y, z] booleans"))?; + } + if !mask.iter().any(|on| *on) { + return Err(bad(cmd, "`axes` must enable at least one of X, Y and Z")); + } + Ok((mask != [true; 3]).then_some(mask)) +} + fn align(s: &mut Session, p: &Value) -> Result { let cmd = "solid.align"; let bodies = string_list(cmd, p, "bodies")?; @@ -470,6 +485,18 @@ fn align(s: &mut Session, p: &Value) -> Result { (a, b) if from_plane.is_some() || to_plane.is_some() => (a, b), _ => (None, None), }; + let axes = axes_param(p, cmd)?; + if axes.is_some() && (from_plane.is_some() || to_plane.is_some() || from_normal.is_some() || to_normal.is_some()) { + return Err(bad(cmd, "`axes` applies to point-to-point alignment: leave out `from_face`, `to_face`, `from_plane` and `to_plane`")); + } + // The directions are stored in the same frame as `from`/`to` (the first moved body's frame). + let axes = axes.map(|mask| { + mask.into_iter() + .zip([Vec3::X, Vec3::Y, Vec3::Z]) + .filter(|&(on, _)| on) + .map(|(_, world)| Dir(solidcraft_doc::apply_vector(&inverse, world).normalized().unwrap_or(world))) + .collect::>() + }); let flip = bool_(p, "flip").unwrap_or(false); add_feature( s, @@ -483,6 +510,7 @@ fn align(s: &mut Session, p: &Value) -> Result { flip, from_plane, to_plane, + axes, }, ) } diff --git a/crates/engine/src/cmd/features_more_tests.rs b/crates/engine/src/cmd/features_more_tests.rs index a3fb1bc8a8..51dc591df8 100644 --- a/crates/engine/src/cmd/features_more_tests.rs +++ b/crates/engine/src/cmd/features_more_tests.rs @@ -833,6 +833,50 @@ fn replace_face_align_remove() { assert_eq!(measure(&mut s)["body_count"], 1); } +#[test] +fn align_can_keep_the_offset_to_chosen_axes() { + let aligned = |axes: Option| { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "A"})); + run(&mut s, "solid.box", json!({"corner": [50, 20, 30], "length": 10, "width": 10, "height": 10, "body_name": "B"})); + let mut p = json!({"bodies": ["A"], "from": [0, 0, 0], "to": [50, 20, 30]}); + if let Some(axes) = axes { + p["axes"] = axes; + } + run(&mut s, "solid.align", p); + s + }; + let min = |s: &mut Session| { + let m = measure(s); + let a = m["bodies"].as_array().unwrap().iter().find(|b| b["name"] == "A").unwrap().clone(); + let g = |i: usize| a["bbox"]["min"][i].as_f64().unwrap_or(f64::NAN); + [g(0), g(1), g(2)] + }; + let close = |got: [f64; 3], want: [f64; 3]| got.iter().zip(want).all(|(a, b)| (a - b).abs() < 1e-6); + assert!(close(min(&mut aligned(Some(json!([false, false, true])))), [0.0, 0.0, 30.0])); + assert!(close(min(&mut aligned(Some(json!([true, false, false])))), [50.0, 0.0, 0.0])); + assert!(close(min(&mut aligned(Some(json!([false, true, false])))), [0.0, 20.0, 0.0])); + assert!(close(min(&mut aligned(Some(json!([true, true, true])))), [50.0, 20.0, 30.0])); + assert!(close(min(&mut aligned(None)), [50.0, 20.0, 30.0])); + + // Refusal cases + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "A"})); + for axes in [json!([false, false, false]), json!([true, false]), json!([true, false, "yes"])] { + assert!(s.execute("solid.align", &json!({"bodies": ["A"], "from": [0, 0, 0], "to": [1, 2, 3], "axes": axes})).is_err(), "{axes}"); + } + // Face/plane alignment cannot take an axis mask. + assert!( + s.execute("solid.align", &json!({"bodies": ["A"], "from_face": [5, 5, 10], "to_face": [5, 5, 20], "axes": [true, false, false]})).is_err() + ); + + // Normalisation: all axes true normalises to None in the saved feature + let made = run(&mut s, "solid.align", json!({"bodies": ["A"], "from": [0, 0, 0], "to": [1, 2, 3], "axes": [true, true, true]})); + let id = made["feature"].as_u64().expect("the feature id"); + let f = s.doc.feature(id).expect("the saved feature"); + assert!(matches!(&f.kind, solidcraft_doc::FeatureKind::Align { axes: None, .. })); +} + /// The z range and the x minimum of body `name`. fn z_x(s: &mut Session, name: &str) -> (f64, f64, f64) { let m = measure(s); @@ -2404,3 +2448,17 @@ fn align_takes_both_ends_where_they_are_shown_across_placed_components() { assert!(near(a.min.z, 8.0) && near(a.max.z, 16.0), "on B's top, not across it: {a:?}"); assert!(near(0.5 * (a.min.x + a.max.x), 60.0) && near(0.5 * (a.min.y + a.max.y), 15.0), "centred on the picked point: {a:?}"); } + +#[test] +fn align_axes_are_world_axes_on_a_placed_component() { + let mut s = Session::default(); + placed_plate(&mut s, "Plate", 0.0, 90.0); + let before = world_bounds(&s, "Plate"); + run(&mut s, "solid.align", json!({"bodies": ["Plate"], "from": [0, 0, 0], "to": [50, 20, 30], "axes": [false, true, false]})); + let after = world_bounds(&s, "Plate"); + // World Y is masked in; X and Z stay where they were. A local-boolean mask would move + // along the component's local Y (world Z) instead. + assert!(near(after.min.x, before.min.x) && near(after.max.x, before.max.x), "x: {after:?}"); + assert!(near(after.min.y, before.min.y + 20.0) && near(after.max.y, before.max.y + 20.0), "y: {after:?}"); + assert!(near(after.min.z, before.min.z) && near(after.max.z, before.max.z), "z: {after:?}"); +} diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 9b47d6b447..77bf1b8f75 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -81,6 +81,8 @@ pub static COMMANDS: &[CommandSpec] = &[ fn new_doc(s: &mut Session, p: &Value) -> Result { let name = str_(p, "name").unwrap_or("Untitled"); s.replace_design(Session::new(Document::new(name))); + // A name given here is the user's own, so Save As keeps it (#4907); "Untitled" is none. + s.user_named = !name.trim().is_empty() && name.trim() != DEFAULT_NAME; Ok(json!({"name": name})) } @@ -370,17 +372,22 @@ fn default_name_for(name: &str, path: &str) -> Option { (!stem.is_empty()).then_some(stem) } -/// The name a design takes when saved to `path`, if it changes: its file's name while it has -/// none of its own ([`default_name_for`]), or while it still has the name it took from the file -/// it was saved as before, so Save As under another name renames it too (#1710). A name the -/// user chose is kept. -fn saved_name(name: &str, old_path: Option<&str>, path: &str) -> Option { +/// The name a design takes when saved to `path`, if it changes. A design with no name of its +/// own takes the file's ([`default_name_for`]). Otherwise a name the user chose is kept, while +/// a borrowed one — taken from the file it was saved as before (#1710), from a sample, or from +/// a file that stored another design's name — follows a Save As to the new file's name, so +/// after Save As the open design *is* the new file (#4907). A plain Save to the same file +/// never renames. +fn saved_name(name: &str, old_path: Option<&str>, path: &str, user_named: bool) -> Option { if let Some(stem) = default_name_for(name, path) { return Some(stem); } + if user_named { + return None; + } let stem = |p: &str| std::path::Path::new(p).file_stem().map(|x| x.to_string_lossy().trim().to_string()).filter(|x| !x.is_empty()); - let (old, new) = (stem(old_path?)?, stem(path)?); - (name.trim() == old && new != old).then_some(new) + let new = stem(path)?; + (old_path != Some(path) && name.trim() != new).then_some(new) } /// Rename the design, and the undo and redo states that still carry the old name, so undo @@ -417,9 +424,9 @@ fn write(s: &mut Session, path: &str, format: &str, compression: &str, preview: ))); } } - // Saving an unnamed design, or one named after the file it was saved as, names it after its - // file (not an undoable edit). - let renamed = saved_name(&s.doc.name, s.path.as_deref(), path).map(|stem| (s.doc.name.clone(), stem)); + // Saving an unnamed or borrowed-named design to a new file names it after that file + // (not an undoable edit); a name the user chose is kept (#4907). + let renamed = saved_name(&s.doc.name, s.path.as_deref(), path, s.user_named).map(|stem| (s.doc.name.clone(), stem)); if let Some((from, to)) = &renamed { rename_design(s, from, to); } @@ -1728,6 +1735,44 @@ mod tests { std::fs::remove_dir_all(dir).unwrap(); } + /// #4907: after Save As the open design *is* the new file — tab, browser and the name stored + /// in the file — whenever its name didn't come from its current file: a sample's name, or a + /// file renamed outside SolidCraft or named by someone else. A plain Save never renames. + #[test] + fn save_as_names_a_borrowed_name_after_the_new_file() { + let dir = name_dir("borrowed"); + // The issue's steps: the sample "Sample Plate" saved as "Bracket v2.solidcraft". + let mut s = Session::default(); + s.doc_mut().name = "Sample Plate".into(); + s.execute("solid.box", &json!({"length":10,"width":20,"height":30})).unwrap(); + let path = dir.join("Bracket v2.solidcraft"); + let r = s.execute("file.save_as", &json!({"path":path})).unwrap(); + assert_eq!(r["name"], "Bracket v2"); + assert_eq!(s.doc.name, "Bracket v2"); + assert!(!s.is_dirty(), "taking the new file's name is part of the save"); + assert_eq!(solidcraft_io::read_design(&std::fs::read(&path).unwrap()).unwrap().name, "Bracket v2"); + // Undoing the box does not bring the sample's name back. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc.name, "Bracket v2"); + // Reopening shows the new name too. + let mut reopened = Session::default(); + reopened.execute("doc.open", &json!({"path":path})).unwrap(); + assert_eq!(reopened.doc.name, "Bracket v2"); + // A file whose stored name is not its own (renamed outside SolidCraft, or saved by a + // version before #1710): Save As names the design after the new file, while a plain + // Save to the file it has leaves the name alone. + let odd = dir.join("renamed.solidcraft"); + std::fs::write(&odd, solidcraft_io::write_design(&Document::new("Sample Plate"))).unwrap(); + let mut s = Session::default(); + s.execute("doc.open", &json!({"path":odd})).unwrap(); + assert_eq!(s.doc.name, "Sample Plate"); + s.execute("file.save", &json!({})).unwrap(); + assert_eq!(s.doc.name, "Sample Plate", "a plain Save does not rename behind the user's back"); + s.execute("file.save_as", &json!({"path":dir.join("Bracket v3.solidcraft")})).unwrap(); + assert_eq!(s.doc.name, "Bracket v3"); + std::fs::remove_dir_all(dir).unwrap(); + } + #[test] fn opening_an_untitled_file_shows_its_file_name() { let dir = name_dir("open"); diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs index 9728d0552d..bfdffebb6c 100644 --- a/crates/engine/src/cmd/inspect.rs +++ b/crates/engine/src/cmd/inspect.rs @@ -59,17 +59,17 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("appearance.favorite", "Favorite Look", appearance_gestures::favorite).icon("star").noundo().doc_only() .params("look: appearance.assign look; favorite: bool → validated look and preference request; UI library preference only, no document change or dialogs."), CommandSpec::new("appearance.chain", "Appearance Chain", appearance_chain::chain).icon("inspect").noundo().params( - "target: {body: name} | {body: name, face: zero-based model.faces index} | {component: name or id}. Returns steps [{step: face|body|component|material, look: {name,color,opacity,pbr?} or null}] in precedence order and active (step or null). Component material is the common default look of its directly owned bodies, null if mixed/empty. Imported paint is a display fallback outside the four steps. Read-only; no dialogs", + "target: {body: name} | {body: name, face: zero-based model.faces index} | {component: name or id}; a placed copy's name (`KB (K:2)`, `K:2`) means the body or component it shows. Returns steps [{step: face|body|component|material, look: {name,color,opacity,pbr?} or null}] in precedence order and active (step or null). Component material is the common default look of its directly owned bodies, null if mixed/empty. Imported paint is a display fallback outside the four steps. Read-only; no dialogs", ), CommandSpec::new("appearance.replace", "Replace Appearance", appearance_chain::replace).icon("appearance").params( - "from: library name | saved look {name,color,opacity,pbr?} (copy appearance.list in_design); to: library name | assignment look {appearance? | color, name?, opacity?, pbr?} (appearance.assign rules; no clear). Matches the full look including name and PBR. scope?: {bodies?: [names], components?: [names or ids]}, at most 10000 targets; body scope includes face overrides, component scope selects only the component override. Omitted scope means all overrides, including hidden/suppressed ones; physical defaults are unchanged. Duplicate targets count once. Returns count of rewritten overrides; zero adds no history. One undo step, atomic; no dialogs. Designs above 100000 overrides refuse", + "from: library name | saved look {name,color,opacity,pbr?} (copy appearance.list in_design); to: library name | assignment look {appearance? | color, name?, opacity?, pbr?} (appearance.assign rules; no clear). Matches the full look including name and PBR. scope?: {bodies?: [names], components?: [names or ids]} (a placed copy's name means the body or component it shows), at most 10000 targets; body scope includes face overrides, component scope selects only the component override. Omitted scope means all overrides, including hidden/suppressed ones; physical defaults are unchanged. Duplicate targets count once. Returns count of rewritten overrides; zero adds no history. One undo step, atomic; no dialogs. Designs above 100000 overrides refuse", ), CommandSpec::new("appearance.library", "Appearance Library", appearance_library).icon("library") .noundo() .params("→ 20 legacy scalar appearances, six opaque PBR finishes and 40 starter recipes with IDs, categories, provenance, requirements and availability; six starter glass recipes are unavailable"), CommandSpec::new("appearance.list", "List Appearances", appearance_list).icon("list") .noundo() - .params("body?: name → assigned appearances; with body, its resolved look and its faces' (face index, look)"), + .params("body?: name (or a placed copy's, `KB (K:2)`: the body it shows) → assigned appearances; with body, its resolved look and its faces' (face index, look)"), CommandSpec::new("material.list", "List Materials", material_list).icon("list") .noundo() .doc_only() @@ -519,12 +519,7 @@ pub(super) fn appearance(s: &mut Session, p: &Value) -> Result { } let mut comps: Vec = Vec::new(); for c in string_list(cmd, p, "components")? { - let id = s - .doc - .find_component(&c) - .or_else(|| s.doc.occurrences.iter().find(|o| o.name == c).map(|o| o.component)) - .filter(|id| *id != 0) - .ok_or_else(|| bad(cmd, format!("no component `{c}`")))?; + let id = s.component_or_placement(&c).ok_or_else(|| bad(cmd, format!("no component `{c}`")))?; comps.push(id); } if bodies.is_empty() && faces.is_empty() && comps.is_empty() { @@ -791,9 +786,12 @@ fn appearance_list(s: &mut Session, p: &Value) -> Result { }); if let Some(name) = str_(p, "body") { let st = s.model.state(); - let b = st.body(name).ok_or_else(|| bad("appearance.list", format!("no body `{name}`")))?; + // A placed copy's name (`KB (K:2)`) means the body it shows, as appearance.assign takes + // it (#9740): the reply names that body. + let own = s.own_body_name(name); + let b = own.as_deref().and_then(|n| st.body(n)).ok_or_else(|| bad("appearance.list", format!("no body `{name}`")))?; out["body"] = json!({ - "name": name, + "name": b.name, "appearance": s.doc.model_body_look(b).map(|l| look_json(&l)), "color": s.doc.body_color(b), "faces": s.doc.face_colors(b).iter().map(|(i, l)| json!({"face": i, "appearance": look_json(l)})).collect::>(), diff --git a/crates/engine/src/cmd/interference.rs b/crates/engine/src/cmd/interference.rs index 453495178c..93f93bf8f7 100644 --- a/crates/engine/src/cmd/interference.rs +++ b/crates/engine/src/cmd/interference.rs @@ -75,6 +75,11 @@ pub(super) fn interference(s: &mut Session, p: &Value) -> Result { let budget = std::time::Duration::from_secs_f64(budget_s); let want = string_list(cmd, p, "bodies")?; let st = s.world_state(); + // A name that is no body is refused, as inspect.clearance refuses it. Dropped, a misspelled + // body was never checked and the answer read as no interference (#9740). + if let Some(n) = want.iter().find(|n| st.body(n).is_none()) { + return Err(bad(cmd, super::no_body(n, st.bodies.iter().map(|b| b.name.as_str())))); + } let (meshes_left, bodies): (Vec<&solidcraft_doc::ModelBody>, Vec<&solidcraft_doc::ModelBody>) = st.bodies.iter().filter(|b| want.is_empty() || want.contains(&b.name)).partition(|b| b.body.is_mesh()); diff --git a/crates/engine/src/cmd/interference_tests.rs b/crates/engine/src/cmd/interference_tests.rs index 2be9c03837..133dd39edf 100644 --- a/crates/engine/src/cmd/interference_tests.rs +++ b/crates/engine/src/cmd/interference_tests.rs @@ -156,3 +156,24 @@ fn mesh_bodies_are_listed_as_unchecked_not_dropped() { assert_eq!(i["checked"], 1, "{i}"); assert_eq!(i["unchecked"][0]["a"].as_str(), Some(meshes[0].as_str()), "{i}"); } + +/// A name that is no body is refused, as inspect.clearance refuses it. Dropped, a misspelled or +/// wrongly cased body was never checked and two overlapping cubes read as no interference (#9740). +#[test] +fn a_body_name_that_is_no_body_is_refused() { + let mut s = 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": [9, 0, 0], "operation": "new_body", "body_name": "B"})); + let i = run(&mut s, "inspect.interference", json!({"bodies": ["A", "B"]})); + assert_eq!((&i["count"], &i["checked"]), (&json!(1), &json!(2)), "{i}"); + for (names, says) in + [(json!(["A", "Typo"]), "no body `Typo`"), (json!(["A", "b"]), "no body `b`; did you mean `B`?"), (json!(["Typo"]), "no body `Typo`")] + { + let e = s.execute("inspect.interference", &json!({"bodies": names})).map(|r| r.to_string()).unwrap_or_else(|e| e.to_string()); + assert!(e.contains(says), "{names}: {e}"); + } + assert!( + !s.execute("inspect.interference", &json!({"bodies": ["Typo"]})).unwrap_err().to_string().contains("did you mean"), + "nothing is near `Typo`" + ); +} diff --git a/crates/engine/src/cmd/joints.rs b/crates/engine/src/cmd/joints.rs index 0608885f1c..2099373278 100644 --- a/crates/engine/src/cmd/joints.rs +++ b/crates/engine/src/cmd/joints.rs @@ -13,8 +13,12 @@ use solidcraft_geom::{Aabb3, Vec3}; use super::CommandSpec; use crate::params::{bad, bool_, num, str_, vec3}; +use crate::schema::joints::EditJointResult; +use crate::schema::{TypedContract, output_schema_for}; use crate::{EngineError, Result, Session}; +const EDIT_CONTRACT: TypedContract = TypedContract::new(); + #[path = "joints_inspect.rs"] mod inspect; @@ -26,7 +30,8 @@ pub static COMMANDS: &[CommandSpec] = &[ "type: rigid|revolute|slider|cylindrical|pin_slot|planar|ball, or a relation: parallel|perpendicular|angle|point_on_plane|point_on_line|line_on_plane \ (ties only some directions or points, made where the parts are; several between two parts add up); a, b: joint origins ({occurrence, face|circle|edge|point: [x,y,z] in world, or face|circle: \"\"; z? for a point, end?, x? as joint.origin} | origin name); \ values?: [angles in deg or expressions, distances]; limits?: [[min, max] | null…]; flip?: bool (default false: the origins are mated, \ - b's z against a's as two faces meet; true: b's z along a's, so two origins given at one point with the same z stay as they are); \ + b's z against a's as two faces meet; true: b's z along a's, so two origins given at one point with the same z and x stay as they are; \ + a point origin without x takes its x from z, so give x on both, or use joint.as_built, to keep a turned part where it is); \ offset?: mm or an expression; angle?: deg or an expression (an angle relation's tilt from mated; for other joints the default \ is none, or a half turn where that turns b less, so faces that already meet stay as they are); name?; quiet?: bool (leave \ out dof and fixed). An expression (text) is \ @@ -41,9 +46,11 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("joint.drive", "Drive Joints", drive).at("SOLID", "ASSEMBLE").icon("drive").params( "joint: id|name; value | values: [angles in deg or expressions, distances] (values not given keep theirs; a value driven drops the expression joint.create gave it; clamped to the limits; the other joints of a \ closed loop follow, and the motion stops where the loop can no longer close → stopped_by_loop); quiet?: bool (leave out dof \ - and fixed) → values: [{name, value, unit: deg|mm, limits}] as joint.list gives them; moved: whether any occurrence moved; \ - reason: why the joint can move nothing (suppressed, its origins lost, or its chain joined to nothing grounded → unconnected: \ - the occurrence named); index?: zero-based coordinate to drive with value (the other coordinates follow); continuous?: bool (drag frame, suppress UI echo)", + and fixed) → values: [{name, value, unit: deg|mm, limits}] as joint.list gives them; requested: the values asked for, in the same form; \ + held: [{name, requested, value, unit, why}] for each driven value that ended up elsewhere (a motion link sets it, as a screw's \ + travel follows its turn; a limit; contact; a closed loop); moved: whether any occurrence moved; \ + reason: why the joint can move nothing (suppressed: nothing is stored; its origins lost; its chain joined to nothing grounded → unconnected: \ + the occurrence named; or, when nothing moved, the first held value's why); index?: zero-based coordinate to drive with value (the other coordinates follow); continuous?: bool (drag frame, suppress UI echo)", ), CommandSpec::new("joint.reach", "Reach Point", reach).icon("reach").params( "occurrence: id|name; point: [x,y,z] (where its point goes, world); at?: [x,y,z] (its point, in the world where it is now; default: the centre of its bodies' box); z?: [x,y,z] (the way its axis then points); axis?: [x,y,z] (that axis, in the world now; default: its own z); joints?: [ids or names] (only these move toward the point; the other joints of a closed loop they are in follow); quiet?: bool; continuous?: bool (a frame of a drag: the answer says continuous, and the app's command line doesn't echo it). Moves the values of the joints on its chain from where they are to the nearest pose, within their limits, through motion links, keeping closed loops closed as joint.drive does (contact sets are not checked) → reached; error_mm (and error_deg with z): how close it got; point: where its point is; values: [{joint, name, values}] of the joints that moved, as joint.list gives them; moved; stopped_by_limit: [{joint, value, limit: min|max}]: the limits it rests on short of the point; stopped_by_loop: {joint}: the closed loop that stopped it (its chain alone gets closer); reason: why nothing can move", @@ -75,9 +82,15 @@ pub static COMMANDS: &[CommandSpec] = &[ "a: the cam's joint (revolute); b: the follower's joint, a slider (a translating follower) or a revolute (an oscillating one), b driven by a; profile: {sketch: id|name, index?: the profile (default: the largest)} | {edges: [[x,y,z]…] (points on edges of the cam's bodies forming one closed loop, world), occurrence?}: the cam's outline, in a plane square to its axis, read again whenever the design changes; follower?: knife|roller|flat (default knife; flat: a face square to a slider's line); radius?: the roller's radius (mm, an expression or {circle: [x,y,z], occurrence?}: the roller's round face, which is its tip too); tip?: a joint origin where the follower touches: its knife's point or its roller's centre, such as {circle: [x,y,z]} on the roller (needed for an oscillating follower; a translating one's line runs through it, by default along its slider's axis); flip?: bool (ride the cam from the other side than the one the follower is on now); link?: index (replace that motion link) → link, lift: {min, max, rise, unit} (b's travel over a turn of the cam), reverse (the side it rides from), follower_offset (mm: how far a translating follower's line passes from the cam's axis); b keeps where it is, also when the link replaces another (at the current pose)", ), CommandSpec::new("joint.limits", "Joint Limits", limits).icon("joint").params("joint; index?: value index (default 0); min?, max? (omit both to clear)"), - CommandSpec::new("joint.edit", "Edit Joint", edit) + CommandSpec::new("joint.edit", "Edit Joint", crate::checked!("joint.edit", edit)) .icon("edit") - .params("joint; type?, flip?, offset?, angle? (a number, or an expression the joint then follows), suppressed?, name?"), + .contract(&EDIT_CONTRACT) + .output(output_schema_for::) + .params( + "joint; a?, b?: joint origins as joint.create takes them (a side left out keeps its place; the two must stay on different occurrences); \ + type?, flip?, offset?, angle? (a number, or an expression the joint then follows), suppressed?, name?; the joint re-solves, keeping \ + its limits and expressions (a relation's values start again where its parts are)", + ), CommandSpec::new("joint.delete", "Delete Joint", delete).icon("delete").params("joint: id|name (or origin: name, link: index)"), CommandSpec::new("joint.list", "List Joints", list).icon("list").noundo().params( "→ joints (type, occurrences, values, limits; expressions: {offset?, angle?, values?} when they were typed as expressions), origins, links, degrees of freedom, conflicts, floating: [{occurrences, joints}] \ @@ -94,7 +107,7 @@ pub static COMMANDS: &[CommandSpec] = &[ ), CommandSpec::new("joint.solve", "Solve Joints", solve_cmd).icon("compute").params("re-place the occurrences from the joints → conflicts, errors, dof"), CommandSpec::new("inspect.interference", "Interference", super::interference::interference).at("SOLID", "INSPECT").icon("interference").noundo().params( - "bodies?: [names] (default: all, in world placement); budget_s?: seconds (default 240, at most 86400: pairs not finished by then are listed in unchecked as \"not checked: the time budget … ran out\" and the call returns) → interferences: pairs that overlap with the overlap volume, area, bounding box and centre (a pin a hair off the axis of a hole of its size also has near_coaxial: {offset_mm, length_mm}, its volume the crescent the walls share); unchecked: [{a, b, reason}], the pairs that could not be checked, with the reason, and each mesh body (STL, 3MF or OBJ import: not intersected) as {a: name, b: null, reason: \"mesh body: not checked\"}, so count: 0 is not read as no overlap (checked counts the solid bodies). Bodies are pruned by bounding box first, then by their display meshes (pairs whose meshes stay apart by more than their chord tolerances, neither inside the other, are clear without a boolean), so any number of bodies is fine; the pairs left are intersected exactly, in parallel on desktop, the slowest first; at most 20000 pairs whose boxes overlap are intersected per call (truncated: true and skipped_pairs: n say when more were left out). Also: pairs_candidate (boxes overlap), pairs_cleared (by the meshes), pairs_intersected, pairs_not_checked, budget_exceeded, threads (worker threads; 0: on the calling thread), boolean_ms and tessellate_ms (kernel time summed over the pairs), slowest: [{a, b, ms, boolean_ms, tessellate_ms}] and ms", + "bodies?: [names] (default: all, in world placement; a name that is no body is refused); budget_s?: seconds (default 240, at most 86400: pairs not finished by then are listed in unchecked as \"not checked: the time budget … ran out\" and the call returns) → interferences: pairs that overlap with the overlap volume, area, bounding box and centre (a pin a hair off the axis of a hole of its size also has near_coaxial: {offset_mm, length_mm}, its volume the crescent the walls share); unchecked: [{a, b, reason}], the pairs that could not be checked, with the reason, and each mesh body (STL, 3MF or OBJ import: not intersected) as {a: name, b: null, reason: \"mesh body: not checked\"}, so count: 0 is not read as no overlap (checked counts the solid bodies). Bodies are pruned by bounding box first, then by their display meshes (pairs whose meshes stay apart by more than their chord tolerances, neither inside the other, are clear without a boolean), so any number of bodies is fine; the pairs left are intersected exactly, in parallel on desktop, the slowest first; at most 20000 pairs whose boxes overlap are intersected per call (truncated: true and skipped_pairs: n say when more were left out). Also: pairs_candidate (boxes overlap), pairs_cleared (by the meshes), pairs_intersected, pairs_not_checked, budget_exceeded, threads (worker threads; 0: on the calling thread), boolean_ms and tessellate_ms (kernel time summed over the pairs), slowest: [{a, b, ms, boolean_ms, tessellate_ms}] and ms", ), ]; @@ -671,19 +684,24 @@ fn limits_param(s: &Session, p: &Value, kind: JointKind, cmd: &str) -> Result Result { - if s.doc.assembly.joints.len() >= 10_000 { - return Err(EngineError::Other("too many joints".into())); - } - // Several parts as bodies of one component (a multi-body STEP import, #1239): say how to - // get two components. - if j.a.occurrence == j.b.occurrence { +/// A joint's two origins on different occurrences (joint.create and joint.edit). Several parts as +/// bodies of one component (a multi-body STEP import, #1239) say how to get two components. +fn check_sides(a: &JointOrigin, b: &JointOrigin, cmd: &str) -> Result<()> { + if a.occurrence == b.occurrence { return Err(bad( - "joint.create", + cmd, "a joint needs two different occurrences: both origins are on one component; give each part a component of its own first \ (component.from_bodies)", )); } + Ok(()) +} + +fn add_joint(s: &mut Session, mut j: Joint, p: &Value) -> Result { + if s.doc.assembly.joints.len() >= 10_000 { + return Err(EngineError::Other("too many joints".into())); + } + check_sides(&j.a, &j.b, "joint.create")?; // It starts at its values inside its limits. A value moved to a limit moves the part as it // is joined, which the answer says. let mut q = j.values.clone(); @@ -882,32 +900,43 @@ fn drive(s: &mut Session, p: &Value) -> Result { if p.get("values").is_none() && p.get("value").is_none() { return Err(bad(cmd, "give `value` or `values`")); } - let doc = s.doc_mut(); - let Some(j) = doc.assembly.joints.iter_mut().find(|j| j.id == id) else { return Err(bad(cmd, "joint")) }; // Values not given (after `value`'s first, or past the end of `values`) keep theirs. let given = match p.get("values") { Some(Value::Array(a)) => a.len(), _ => 1, }; + let joint = s.doc.assembly.joints.iter().find(|j| j.id == id).ok_or_else(|| bad(cmd, "joint"))?; if index.is_none() { for (i, v) in q.iter_mut().enumerate().skip(given) { - *v = j.values.get(i).copied().unwrap_or(*v); + *v = joint.values.get(i).copied().unwrap_or(*v); } } - joints::clamp(j, &mut q); - // Moved as far toward `q` as contact sets and closed loops allow. Values left out of - // `values` are not driven: in a closed loop they follow. - // Driving moves the joint by hand: the values driven drop their expressions (an expression - // given here is worked out once, as contact or a loop may stop the motion short of it). - if let Some(index) = index { - j.exprs.set_value(index, None); + // What was asked for, before limits and motion links have their say. + let requested = q.clone(); + let driven: Vec = match index { + Some(index) => vec![index], + None => (0..given.min(kind.dofs().len())).collect(), + }; + // A suppressed joint places nothing: a value stored now would move the part only when the + // joint is unsuppressed, and leave an undo step for a drive whose reply says nothing moved + // (#9742). A joint in a motion link still carries its value to the joints it drives. + let hold = joint.suppressed && !s.doc.assembly.links.iter().any(|l| l.a == id || l.b == id); + let before = super::motion::positions(s); + let r = if hold { + json!({"stopped_by_contact": null, "stopped_by_loop": null}) } else { - for i in 0..given.min(kind.dofs().len()) { + let doc = s.doc_mut(); + let Some(j) = doc.assembly.joints.iter_mut().find(|j| j.id == id) else { return Err(bad(cmd, "joint")) }; + joints::clamp(j, &mut q); + // Moved as far toward `q` as contact sets and closed loops allow. Values left out of + // `values` are not driven: in a closed loop they follow. + // Driving moves the joint by hand: the values driven drop their expressions (an expression + // given here is worked out once, as contact or a loop may stop the motion short of it). + for &i in &driven { j.exprs.set_value(i, None); } - } - let before = super::motion::positions(s); - let r = if let Some(index) = index { super::motion::drive_joint_index(s, id, q, index)? } else { super::motion::drive_joint(s, id, q, given)? }; + if let Some(index) = index { super::motion::drive_joint_index(s, id, q, index)? } else { super::motion::drive_joint(s, id, q, given)? } + }; let sol = solution(s, &BTreeMap::new(), response_analysis(p)); let mut out = sol_json(&sol, p); out["joint"] = json!(id); @@ -917,7 +946,20 @@ fn drive(s: &mut Session, p: &Value) -> Result { } out["stopped_by_contact"] = r["stopped_by_contact"].clone(); out["stopped_by_loop"] = r["stopped_by_loop"].clone(); - out["moved"] = json!(super::motion::moved(&before, &super::motion::positions(s)).next().is_some()); + let moved = super::motion::moved(&before, &super::motion::positions(s)).next().is_some(); + out["moved"] = json!(moved); + if let Some(j) = s.doc.assembly.joints.iter().find(|j| j.id == id) { + out["requested"] = json!(named_values(j, &requested)); + let held = if hold { Vec::new() } else { held_values(s, j, &requested, &driven, &r) }; + if let Some(first) = held.first() + && !moved + { + out["reason"] = first["why"].clone(); + } + if !held.is_empty() { + out["held"] = json!(held); + } + } if let Some((why, unconnected)) = cannot_move(s, &sol, id) { out["reason"] = json!(why); if let Some(o) = unconnected { @@ -927,6 +969,52 @@ fn drive(s: &mut Session, p: &Value) -> Result { Ok(out) } +/// The driven values of `j` that ended up other than `requested`, each with why: a motion link +/// sets it from another coordinate (a screw's travel follows its turn), a limit holds it, or +/// contact or a closed loop stopped the motion short. Degrees and mm, as `values` (#9742). +fn held_values(s: &Session, j: &Joint, requested: &[f64], driven: &[usize], r: &Value) -> Vec { + let now = joints::joint_values(&s.doc.assembly).remove(&j.id).unwrap_or_else(|| j.values.clone()); + let names = j.kind.dofs(); + let mut out = Vec::new(); + for &i in driven { + let (Some(&want), Some(&got)) = (requested.get(i), now.get(i)) else { continue }; + if (want - got).abs() <= 1e-9 * want.abs().max(got.abs()).max(1.0) { + continue; + } + let angle = j.kind.is_angle(i); + let shown = |v: f64| if angle { v.to_degrees() } else { v }; + let name = names.get(i).copied().unwrap_or("value"); + let why = if let Some(l) = s.doc.assembly.links.iter().find(|l| l.b == j.id && l.ib == i) { + let coupling = match &l.coupling { + Some(joints::Coupling::Gear { .. }) => " (gears)", + Some(joints::Coupling::RackPinion { .. }) => " (rack and pinion)", + Some(joints::Coupling::Screw { .. }) => " (screw)", + Some(joints::Coupling::Cam(_)) => " (cam)", + None => "", + }; + let from = s.doc.assembly.joints.iter().find(|o| o.id == l.a); + let coordinate = from.and_then(|o| o.kind.dofs().get(l.ia).copied()).unwrap_or("value"); + if l.a == j.id { + format!("{name} follows its {coordinate} through a motion link{coupling}") + } else { + format!("{name} follows {}'s {coordinate} through a motion link{coupling}", from.map_or("another joint", |o| o.name.as_str())) + } + } else if let Some(Some((lo, hi))) = j.limits.get(i) + && (want < lo.min(*hi) || want > hi.max(*lo)) + { + format!("{name} is held to its limits, {} to {} {}", shown(lo.min(*hi)), shown(hi.max(*lo)), if angle { "deg" } else { "mm" }) + } else if r["stopped_by_contact"].is_object() { + format!("{name} stopped at contact") + } else if r["stopped_by_loop"].is_object() { + format!("{name} stopped where a closed loop can no longer close") + } else { + continue; + }; + out.push(json!({"name": name, "requested": shown(want), "value": shown(got), "unit": if angle { "deg" } else { "mm" }, "why": why})); + } + out +} + /// Joints `joint.reach` takes at most in `joints`. const MAX_REACH_JOINTS: usize = 1000; @@ -1366,8 +1454,22 @@ fn edit(s: &mut Session, p: &Value) -> Result { Some(v) => Some(value(s, v, true, cmd)?), None => None, }; + // The sides, read as joint.create reads them; a side left out keeps its place. + let a = p.get("a").map(|v| origin_param(s, Some(v), cmd)).transpose()?; + let b = p.get("b").map(|v| origin_param(s, Some(v), cmd)).transpose()?; + let sides = a.is_some() || b.is_some(); + if sides { + let j = s.doc.assembly.joints.iter().find(|j| j.id == id).ok_or_else(|| bad(cmd, "joint"))?; + check_sides(a.as_ref().unwrap_or(&j.a), b.as_ref().unwrap_or(&j.b), cmd)?; + } let doc = s.doc_mut(); let Some(j) = doc.assembly.joints.iter_mut().find(|j| j.id == id) else { return Err(bad(cmd, "joint")) }; + if let Some(a) = a { + j.a = a; + } + if let Some(b) = b { + j.b = b; + } if let Some(k) = kind { let was = j.kind; j.kind = k; @@ -1399,8 +1501,19 @@ fn edit(s: &mut Session, p: &Value) -> Result { if let Some(x) = bool_(p, "suppressed") { suppress_joints(s.doc_mut(), &[id], x); } + // A side moved without an angle turns B as joint.create would: the least turn from where it + // is now (a relation's angle is its tilt, which stays; an angle expression keeps driving it). + if sides + && p.get("angle").is_none() + && let Some(j) = s.doc.assembly.joints.iter().find(|j| j.id == id).filter(|j| !j.kind.is_relation() && j.exprs.angle.is_none()).cloned() + { + let turn = joints::least_turn_angle(&s.doc, &s.model.state(), &j); + if let Some(j) = s.doc_mut().assembly.joints.iter_mut().find(|j| j.id == id) { + j.angle = turn; + } + } // A relation whose type, offset, tilt or flip changes starts again from where its parts are. - let reseat = kind.is_some() || offset.is_some() || angle.is_some() || p.get("flip").is_some(); + let reseat = kind.is_some() || offset.is_some() || angle.is_some() || p.get("flip").is_some() || sides; if reseat { let edited = s.doc.assembly.joints.iter().find(|j| j.id == id).cloned(); if let Some(q) = edited.and_then(|j| joints::relation_values(&s.doc, &s.model.state(), &j)) diff --git a/crates/engine/src/cmd/joints_tests.rs b/crates/engine/src/cmd/joints_tests.rs index 6bf63d80d0..64abf18e20 100644 --- a/crates/engine/src/cmd/joints_tests.rs +++ b/crates/engine/src/cmd/joints_tests.rs @@ -304,6 +304,114 @@ fn faces_mate_by_default() { assert!(near(place(&s, "B", Vec3::new(25.0, 5.0, 10.0)), Vec3::new(5.0, 5.0, 0.0)), "{:?}", place(&s, "B", Vec3::new(25.0, 5.0, 10.0))); } +/// Edit Joint takes new sides as joint.create does: the joint moves its attachment to them, and +/// keeps its limits, its drive and the expression its offset follows. +#[test] +fn edit_moves_a_side_and_keeps_limits_and_expressions() { + let mut s = two_cubes(); + run(&mut s, "parameters.add", json!({"name": "gap", "expression": "2 mm"})); + run( + &mut s, + "joint.create", + json!({"type": "slider", "name": "Rail", "offset": "gap", "limits": [[0, 10]], "a": {"face": [5, 5, 10]}, "b": {"face": [25, 5, 0]}}), + ); + run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 4})); + // B's bottom centre rides 2 mm (the gap) plus 4 mm (the drive) above A's top centre. + assert!(near(place(&s, "B", Vec3::new(25.0, 5.0, 0.0)), Vec3::new(5.0, 5.0, 16.0)), "{:?}", place(&s, "B", Vec3::new(25.0, 5.0, 0.0))); + // B's top centre, where B is placed now, is the new side (`face` is a world point). + let top = place(&s, "B", Vec3::new(25.0, 5.0, 10.0)); + let r = run(&mut s, "joint.edit", json!({"joint": "Rail", "b": {"occurrence": "B", "face": [top.x, top.y, top.z]}})); + assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(0), "{r}"); + assert!(near(place(&s, "B", Vec3::new(25.0, 5.0, 10.0)), Vec3::new(5.0, 5.0, 16.0)), "{:?}", place(&s, "B", Vec3::new(25.0, 5.0, 10.0))); + let j = s.doc.assembly.joints.first().cloned().unwrap_or_else(|| panic!("no joint")); + assert_eq!(j.limits, vec![Some((0.0, 10.0))]); + assert_eq!(j.exprs.offset.as_deref(), Some("gap")); + assert_eq!(j.values, vec![4.0]); + assert_eq!(j.name, "Rail"); +} + +/// Sides left out of joint.edit stay as they are; the other parameters still change. +#[test] +fn omitted_sides_stay() { + let mut s = two_cubes(); + run(&mut s, "joint.create", json!({"type": "rigid", "name": "Stack", "a": {"face": [5, 5, 10]}, "b": {"face": [25, 5, 0]}})); + let before = s.doc.assembly.joints[0].clone(); + run(&mut s, "joint.edit", json!({"joint": "Stack", "flip": true, "name": "Flipped"})); + let after = &s.doc.assembly.joints[0]; + assert_eq!((&after.a, &after.b), (&before.a, &before.b)); + assert!(after.flip && after.name == "Flipped"); +} + +/// One undo step puts the sides back, and the parts with them. +#[test] +fn one_undo_restores_the_old_sides() { + let mut s = two_cubes(); + run(&mut s, "joint.create", json!({"type": "rigid", "name": "Stack", "a": {"face": [5, 5, 10]}, "b": {"face": [25, 5, 0]}})); + let before = s.doc.assembly.joints[0].clone(); + let top = place(&s, "B", Vec3::new(25.0, 5.0, 10.0)); + run(&mut s, "joint.edit", json!({"joint": "Stack", "b": {"occurrence": "B", "face": [top.x, top.y, top.z]}})); + assert!(near(place(&s, "B", Vec3::new(25.0, 5.0, 10.0)), Vec3::new(5.0, 5.0, 10.0)), "{:?}", place(&s, "B", Vec3::new(25.0, 5.0, 10.0))); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.assembly.joints[0], before); + assert!(near(place(&s, "B", Vec3::new(25.0, 5.0, 0.0)), Vec3::new(5.0, 5.0, 10.0))); +} + +/// Moving a side re-solves the joint as joint.create would at the same pose: the turn B keeps +/// about the joint's axis follows its new origin, so B does not turn by a half turn (or any turn +/// a fresh create would not give). Both designs start from the same pose; one moves the side, +/// the other deletes the joint and creates it with the new sides. +#[test] +fn moving_a_side_places_the_part_as_create_would() { + let a_faces = [[5, 5, 10], [5, 0, 5], [5, 10, 5], [10, 5, 5], [0, 5, 5]]; + let b_faces = [[25, 5, 0], [25, 5, 10], [25, 0, 5], [25, 10, 5], [20, 5, 5], [30, 5, 5]]; + // A point of B off the axes of every face above. + let probe = |s: &Session| place(s, "B", Vec3::new(22.0, 3.0, 7.0)); + let mut differ = Vec::new(); + for a in a_faces { + for b in b_faces { + let mut edited = two_cubes(); + run(&mut edited, "joint.create", json!({"type": "rigid", "name": "J", "a": {"face": [5, 5, 10]}, "b": {"face": [25, 5, 0]}})); + let mut created = Session::new(solidcraft_doc::Document::from_json(&edited.doc.to_json()).unwrap()); + // The new sides in world coordinates, where B is before the move. + let (aw, bw) = (Vec3::new(a[0] as f64, a[1] as f64, a[2] as f64), place(&edited, "B", Vec3::new(b[0] as f64, b[1] as f64, b[2] as f64))); + let side = |occurrence: &str, p: Vec3| json!({"occurrence": occurrence, "face": [p.x, p.y, p.z]}); + run(&mut edited, "joint.edit", json!({"joint": "J", "a": side("A", aw), "b": side("B", bw)})); + run(&mut created, "joint.delete", json!({"joint": "J"})); + run(&mut created, "joint.create", json!({"type": "rigid", "name": "J", "a": side("A", aw), "b": side("B", bw)})); + if !near(probe(&edited), probe(&created)) { + differ.push((a, b, probe(&edited), probe(&created))); + } + } + } + assert!(differ.is_empty(), "moved sides place B differently from create: {differ:?}"); +} + +/// A side that makes no joint is refused, and the joint is left as it was: a side on the other +/// side's component, a side that is not a joint origin, an unknown occurrence. +#[test] +fn an_invalid_side_is_refused_and_the_joint_is_kept() { + let mut s = two_cubes(); + run(&mut s, "joint.create", json!({"type": "rigid", "name": "Stack", "a": {"face": [5, 5, 10]}, "b": {"face": [25, 5, 0]}})); + let before = s.doc.assembly.joints[0].clone(); + // A is a's occurrence and B is b's: each side is tried on the other's occurrence, and with + // the faults every side can have. + let cases = [ + ("a", json!({"occurrence": "B", "face": [25, 5, 10]})), + ("a", json!({"occurrence": "Nope", "face": [5, 5, 10]})), + ("a", json!({"occurrence": "A"})), + ("a", json!(5)), + ("b", json!({"occurrence": "A", "face": [5, 5, 0]})), + ("b", json!({"occurrence": "Nope", "face": [25, 5, 0]})), + ("b", json!({"occurrence": "B"})), + ("b", json!("NoSuchOrigin")), + ]; + for (key, side) in cases { + let r = s.execute("joint.edit", &json!({"joint": "Stack", key: side.clone()})); + assert!(r.is_err(), "{key}: {side} should be refused"); + assert_eq!(s.doc.assembly.joints[0], before, "{key}: {side} changed the joint"); + } +} + /// A move of a jointed part that the joint takes back: the model is evaluated for the /// placements the solve leaves, so a mirror of the part's body, made from the root, is where a /// fresh evaluation of the design puts it. The model was the one evaluated for the move before @@ -1179,6 +1287,87 @@ fn hidden_joints_still_drive_and_are_saved_with_the_design() { let _ = std::fs::remove_dir_all(&dir); } +/// #3892: a large nested STEP assembly hides thousands of joints, and the browser asks about +/// every joint of the design on every frame: the answer must not walk the hidden-joint list, +/// so the work must not grow with the number of hidden joints. The hidden joint stays hidden, +/// the shown one stays shown, and undo and redo keep the hiding right. +#[test] +fn hidden_joint_lookups_do_not_walk_the_hidden_joint_list() { + use crate::occurrences::visibility_work; + + let mut s = hinged(); + let (hinge, glue) = (joint(&s, "Hinge"), joint(&s, "Glue")); + + // The hidden joints of an imported assembly: thousands of ids (#3892). + let hide_many = |s: &mut Session, n: u64| { + s.visibility.hidden_joints.clear(); + for id in 0..n { + s.visibility.hidden_joints.push(id); + } + }; + // The entries one lookup walked, for a joint that is not hidden. + let walked_by = |s: &Session, id: u64| { + visibility_work::take(); + let visible = s.joint_visible(id); + let walked = visibility_work::take(); + assert!(visible, "joint {id} is not hidden here"); + walked + }; + + hide_many(&mut s, 10); + let few = walked_by(&s, 9_000_001); + hide_many(&mut s, 10_000); + let many = walked_by(&s, 9_000_001); + assert!( + many <= few + 4, + "a joint lookup walked {many} entries of a 10,000-entry hidden-joint list and {few} of a \ + 10-entry one: the work must not grow with the number of hidden joints" + ); + + // The behaviour is unchanged: the hidden joint is hidden, the shown one is shown, and undo + // and redo keep the hiding right. + s.visibility.hidden_joints.clear(); + run(&mut s, "browser.visibility", json!({"joints": ["Hinge"], "visible": false})); + assert!(!s.joint_visible(hinge) && s.joint_visible(glue)); + run(&mut s, "edit.undo", json!({})); + assert!(s.joint_visible(hinge), "undo shows the hinge again"); + run(&mut s, "edit.redo", json!({})); + assert!(!s.joint_visible(hinge), "redo hides it again"); +} + +/// The cost of a frame's worth of joint questions, with few and with many hidden joints: the +/// manual measurement behind #3892. Run it with `--ignored --nocapture`. +#[test] +#[ignore = "manual scaling measurement"] +fn joint_visibility_lookup_cost_by_hidden_joint_count() { + use crate::occurrences::visibility_work; + + let joints = 1_000u64; + for hidden in [0u64, 10, 100, 1_000, 10_000] { + let mut s = Session::default(); + for id in 0..hidden { + s.visibility.hidden_joints.push(id); + } + let mut times = Vec::new(); + for _ in 0..11 { + let start = std::time::Instant::now(); + for id in 0..joints { + let _ = s.joint_visible(id + hidden + 1); + } + times.push(start.elapsed().as_secs_f64() * 1e3); + } + times.sort_by(f64::total_cmp); + // The index is built once per change of the view, apart from the answers themselves. + println!( + "hidden_joints={hidden:>6} questions={joints} median_ms={:.3} min_ms={:.3} max_ms={:.3} built_entries={}", + times[times.len() / 2], + times[0], + times[times.len() - 1], + visibility_work::built_take() + ); + } +} + /// #1160: a design saved before joints could be hidden (format 19, no `hidden_joints`) opens /// with every joint shown; and a new joint that takes a deleted hidden joint's id starts shown. #[test] @@ -1302,8 +1491,52 @@ fn quiet_joint_answers_and_a_drive_to_where_it_is() { assert!(r["dof"].is_array() && r["moved"] == false && r.get("reason").is_none(), "{r}"); // A suppressed joint with no motion link moves nothing, and says why. run(&mut s, "joint.edit", json!({"joint": "Rail", "suppressed": true})); + let (doc, undo) = (s.doc.clone(), s.undo.len()); let r = run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 8})); assert!(r["moved"] == false && r["reason"] == "Rail is suppressed" && r.get("unconnected").is_none(), "{r}"); + // Nor does it store the value, as it did with an undo step: unsuppressed, the part jumped to + // it (#9742). The reply keeps what was asked apart from what the joint holds. + assert!(std::sync::Arc::ptr_eq(&doc, &s.doc) && s.undo.len() == undo, "a drive of a suppressed joint changed the design"); + assert_eq!((&r["requested"][0]["value"], &r["values"][0]["value"]), (&json!(8.0), &json!(5.0)), "{r}"); + assert!(r.get("held").is_none(), "{r}"); +} + +/// A screw's travel follows its turn, so a distance asked of it is not reached: the reply says +/// what was asked and why the joint holds another value, and a drive of the distance alone, +/// which moves nothing, says why. A limit is named the same way (#9742). +#[test] +fn a_drive_says_which_values_a_motion_link_or_a_limit_held() { + let mut s = two_cubes(); + run(&mut s, "joint.create", json!({"type": "cylindrical", "a": {"face": [5, 5, 10]}, "b": {"face": [25, 5, 0]}, "name": "Cy"})); + run(&mut s, "joint.screw", json!({"joint": "Cy", "pitch": 2})); + let follows = json!("distance follows its angle through a motion link (screw)"); + let r = run(&mut s, "joint.drive", json!({"joint": "Cy", "values": [90, 5]})); + assert!(r["moved"] == true && r.get("reason").is_none(), "{r}"); + assert_eq!((&r["requested"][0]["value"], &r["requested"][1]["value"]), (&json!(90.0), &json!(5.0)), "{r}"); + let held = r["held"].as_array().cloned().unwrap_or_default(); + assert_eq!(held.len(), 1, "only the distance is held: {r}"); + assert_eq!( + (&held[0]["name"], &held[0]["requested"], &held[0]["unit"], &held[0]["why"]), + (&json!("distance"), &json!(5.0), &json!("mm"), &follows), + "{r}" + ); + assert!((held[0]["value"].as_f64().unwrap_or(f64::NAN) - 0.5).abs() < 1e-9, "a quarter turn at pitch 2 travels 0.5 mm: {r}"); + // The distance alone: nothing moves, and the reply says why. + let r = run(&mut s, "joint.drive", json!({"joint": "Cy", "index": 1, "value": 4})); + assert_eq!((&r["moved"], &r["reason"], &r["held"][0]["requested"]), (&json!(false), &follows, &json!(4.0)), "{r}"); + + let mut s = two_cubes(); + run(&mut s, "joint.as_built", json!({"type": "slider", "a": "A", "b": "B", "name": "Rail", "limits": [[0, 5]]})); + let r = run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 20})); + assert!(r["moved"] == true && r.get("reason").is_none(), "{r}"); + assert_eq!( + r["held"], + json!([{"name": "distance", "requested": 20.0, "value": 5.0, "unit": "mm", "why": "distance is held to its limits, 0 to 5 mm"}]), + "{r}" + ); + // Within the limits, nothing is held. + let r = run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 3})); + assert!(r.get("held").is_none() && r["requested"][0]["value"] == 3.0, "{r}"); } /// An as-built joint takes a name and limits like any other joint. diff --git a/crates/engine/src/cmd/mass_properties.rs b/crates/engine/src/cmd/mass_properties.rs new file mode 100644 index 0000000000..2922ecf339 --- /dev/null +++ b/crates/engine/src/cmd/mass_properties.rs @@ -0,0 +1,363 @@ +//! Mass, centre of mass and inertia tensor per occurrence and per rigid group (#9715): what a +//! multibody dynamics solver needs of each moving part. +//! +//! Bodies are measured as `component.properties` measures them (docs/assembly-properties.md): +//! volume and centre from the kernel's measurement, the inertia from how the welded export mesh +//! spreads that volume (`solidcraft_geom::inertia`), at the body's material density (the default +//! material's when none is assigned). Each body is measured once however often it is placed; +//! each placement moves its centre and turns its tensor, and an occurrence's bodies combine by +//! the parallel-axis theorem. An open body adds no mass and is counted. +//! +//! Rigid groups are the occurrences that move as one body: those an unsuppressed rigid joint +//! ties together, and a nested occurrence that no joint places with the occurrence it sits in +//! (the joint solver leaves it where its parent puts it). A group holding the design itself or +//! a grounded occurrence is fixed. Occurrences are taken where the joint solver places them, so +//! an occurrence inside a subassembly placed more than once is listed at the subassembly's first +//! placement (a warning says so). + +use std::collections::{BTreeMap, HashMap, HashSet}; + +use serde_json::Value; +use solidcraft_doc::joints::{self, JointKind, MAX_JOINTS}; +use solidcraft_doc::{Document, IDENTITY, MAX_PLACEMENTS, Mat, ModelState, apply_point, mat_inverse, mat_mul}; +use solidcraft_geom::Vec3; +use solidcraft_geom::inertia::{self, Tensor3}; + +use super::CommandSpec; +use crate::params::bad; +use crate::schema::mass_properties::{AssemblyMassParams, AssemblyMassResult, BodyMassEntry, MassFrame, OccurrenceMass, RigidGroupMass, RigidMass}; +use crate::schema::{TypedContract, output_schema_for}; +use crate::{EngineError, Result, Session}; + +const CMD: &str = "assembly.mass_properties"; +const CONTRACT: TypedContract = TypedContract::new(); + +pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new(CMD, "Mass Properties", crate::checked!("assembly.mass_properties", run)) + .icon("measure") + .noundo() + .contract(&CONTRACT) + .output(output_schema_for::) + .params( + "no parameters → occurrences: [{occurrence (0: the design's own bodies), name, component, path, group, fixed, frame \ + {origin, x, y, z}, bodies: [{name, material, density_g_cm3, closed, volume_mm3, mass_kg}], open_bodies_skipped, mass}], \ + groups: [{group, occurrences, fixed, frame_occurrence, frame, mass}] (occurrences an unsuppressed rigid joint ties \ + together, and a nested occurrence no joint places with its parent; fixed: holds the design or a grounded occurrence), \ + warnings. mass: {mass_kg, center_of_mass_mm (world), center_of_mass_local_mm, inertia_world_kg_mm2, \ + inertia_local_kg_mm2 (about the centre of mass, in world and frame axes)} or null when massless. Read-only; pending \ + moves count; each body at its material's density (the default material's when none is assigned)", + )]; + +/// One rigid body's mass properties: mass (kg), centre of mass (mm, world) and the inertia +/// tensor about it (kg·mm², world axes; `None` when a closed body's mesh did not close). +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct MassBody { + pub mass: f64, + pub center: Vec3, + pub inertia: Option, +} + +impl MassBody { + pub fn is_finite(&self) -> bool { + self.mass.is_finite() && self.center.is_finite() && self.inertia.is_none_or(|i| i.iter().flatten().all(|x| x.is_finite())) + } + + /// Bodies taken as one: the masses add, the centre is mass-weighted, and each tensor moves to + /// the common centre by the parallel-axis theorem. The tensor is `None` when any part's is. + /// `None` when the total mass is not positive or a value is not finite. + pub fn combine(parts: &[MassBody]) -> Option { + let mass: f64 = parts.iter().map(|p| p.mass).sum(); + if !(mass > 0.0 && mass.is_finite()) { + return None; + } + let center = parts.iter().fold(Vec3::ZERO, |acc, p| acc + p.center * p.mass) * (1.0 / mass); + let inertia = parts.iter().try_fold([[0.0f64; 3]; 3], |sum, p| { + let i = inertia::shifted(&p.inertia?, p.mass, p.center - center); + Some([0, 1, 2].map(|r| [0, 1, 2].map(|k| sum[r][k] + i[r][k]))) + }); + let out = MassBody { mass, center, inertia }; + out.is_finite().then_some(out) + } +} + +/// The 3 × 3 linear part of a placement, row by row (`Mat` is column-major). +fn linear(m: &Mat) -> Tensor3 { + [0, 1, 2].map(|i| [0, 1, 2].map(|j| m[j][i])) +} + +fn transpose(a: &Tensor3) -> Tensor3 { + [0, 1, 2].map(|i| [0, 1, 2].map(|j| a[j][i])) +} + +fn det3(a: &Tensor3) -> f64 { + a[0][0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1]) - a[0][1] * (a[1][0] * a[2][2] - a[1][2] * a[2][0]) + + a[0][2] * (a[1][0] * a[2][1] - a[1][1] * a[2][0]) +} + +/// The rotation (or reflection) a placement's axes make, when they are orthonormal. +fn rotation(m: &Mat) -> Option { + let a = linear(m); + let ata = inertia::transformed(&[[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], &transpose(&a)); + let ok = (0..3).all(|i| (0..3).all(|j| (ata[i][j] - if i == j { 1.0 } else { 0.0 }).abs() <= 1e-9)); + ok.then_some(a) +} + +fn xyz(v: Vec3) -> [f64; 3] { + [v.x, v.y, v.z] +} + +fn frame_of(m: &Mat) -> MassFrame { + let axis = |k: usize| { + let v = Vec3::new(m[k][0], m[k][1], m[k][2]); + let len = v.len(); + xyz(if len > 0.0 && len.is_finite() { v * (1.0 / len) } else { v }) + }; + MassFrame { origin: [m[3][0], m[3][1], m[3][2]], x: axis(0), y: axis(1), z: axis(2) } +} + +/// A world [`MassBody`] as the frame `m` sees it. +fn rigid_mass(b: &MassBody, m: &Mat) -> RigidMass { + let local = mat_inverse(m).map(|inv| apply_point(&inv, b.center)).unwrap_or(b.center); + RigidMass { + mass_kg: b.mass, + center_of_mass_mm: xyz(b.center), + center_of_mass_local_mm: xyz(local), + inertia_world_kg_mm2: b.inertia, + // Rᵀ I R: the world tensor in the frame's axes. + inertia_local_kg_mm2: b.inertia.zip(rotation(m)).map(|(i, r)| inertia::transformed(&i, &transpose(&r))), + } +} + +/// Union-find over occurrence indices; the lower index roots a group, so the design (index 0) +/// roots its own. +struct Groups(Vec); + +impl Groups { + fn root(&self, mut i: usize) -> usize { + // Bounded: each step goes to a lower index. + for _ in 0..self.0.len() { + match self.0.get(i) { + Some(&parent) if parent != i => i = parent, + _ => break, + } + } + i + } + fn join(&mut self, a: usize, b: usize) { + let (ra, rb) = (self.root(a), self.root(b)); + if let Some(slot) = self.0.get_mut(ra.max(rb)) { + *slot = ra.min(rb); + } + } +} + +/// Occurrence ids from the top of the assembly down, as `joints::occurrence_world` walks them. +fn canonical_path(doc: &Document, occ: u64) -> Vec { + let mut path = Vec::new(); + let mut cur = doc.occurrences.iter().find(|o| o.id == occ); + for _ in 0..1000 { + let Some(o) = cur else { break }; + path.push(o.id); + if o.parent == 0 { + break; + } + cur = doc.occurrence_of(o.parent); + } + path.reverse(); + path +} + +fn out_of_range() -> EngineError { + bad(CMD, "mass properties exceed supported numeric range") +} + +impl Session { + /// Mass, centre of mass and inertia of every occurrence and rigid group of the design, as + /// `assembly.mass_properties` returns them (kg, mm, kg·mm²), pending moves included. + pub fn mass_properties(&self) -> Result { + let mut doc = (*self.doc).clone(); + for occurrence in &mut doc.occurrences { + if let Some(transform) = self.pending_moves.get(&occurrence.id) { + occurrence.transform = *transform; + } + } + let state = self.model.state(); + assembly(&doc, &state) + } +} + +/// A body measured once: closed, and if so its volume (mm³), centre (mm) and spread, all in its +/// component's frame before its offset. +struct Shape { + closed: bool, + volume: f64, + centroid: Vec3, + gyration: Option, +} + +/// [`Session::mass_properties`] of a document and its evaluated state. +pub fn assembly(doc: &Document, state: &ModelState) -> Result { + if doc.occurrences.len() >= MAX_PLACEMENTS { + return Err(bad(CMD, format!("assembly exceeds {MAX_PLACEMENTS} occurrences"))); + } + let mut warnings = Vec::new(); + // Index 0 is the design itself; then the occurrences by id. + let mut ids: Vec = doc.occurrences.iter().map(|o| o.id).filter(|id| *id != 0).collect(); + ids.sort_unstable(); + ids.dedup(); + ids.insert(0, 0); + let index: HashMap = ids.iter().enumerate().map(|(i, id)| (*id, i)).collect(); + + let mut groups = Groups((0..ids.len()).collect()); + let live: Vec<_> = doc.assembly.joints.iter().filter(|j| !j.suppressed).take(MAX_JOINTS).collect(); + let jointed: HashSet = live.iter().flat_map(|j| [j.a.occurrence, j.b.occurrence]).collect(); + for j in live.iter().filter(|j| j.kind == JointKind::Rigid) { + if let (Some(&a), Some(&b)) = (index.get(&j.a.occurrence), index.get(&j.b.occurrence)) { + groups.join(a, b); + } + } + for o in doc.occurrences.iter().filter(|o| o.parent != 0 && !jointed.contains(&o.id)) { + if let (Some(&child), Some(&parent)) = (index.get(&o.id), doc.occurrence_of(o.parent).and_then(|p| index.get(&p.id))) { + groups.join(child, parent); + } + } + for c in doc.components.iter().filter(|c| doc.occurrences.iter().any(|o| o.parent == c.id)) { + let copies = doc.occurrences.iter().filter(|o| o.component == c.id).count(); + if copies > 1 { + warnings.push(format!( + "`{}` is placed {copies} times: the occurrences inside it are listed at its first placement only, where joints place them", + c.name + )); + } + } + + // Each placed body measured once, on worker threads. + let owners = doc.model_body_components(state); + let placed: HashSet = + ids.iter().map(|id| if *id == 0 { 0 } else { doc.occurrences.iter().find(|o| o.id == *id).map_or(u64::MAX, |o| o.component) }).collect(); + let measured: Vec = (0..state.bodies.len()).filter(|i| owners.get(*i).is_some_and(|c| placed.contains(c))).collect(); + let shapes = solidcraft_kernel::map_on_threads(&measured, 16, |&i| -> Result { + let body = state.bodies.get(i).ok_or_else(|| EngineError::Other("body index out of range".into()))?; + let closed = crate::cmd::inspect::encloses_volume(&body.body); + if !closed { + return Ok(Shape { closed, volume: 0.0, centroid: Vec3::ZERO, gyration: None }); + } + let m = solidcraft_kernel::measure_mass(&body.body)?; + let gyration = solidcraft_io::mass_properties(&body.body)?.and_then(|p| p.gyration()); + Ok(Shape { closed, volume: m.volume, centroid: m.centroid, gyration }) + }); + let mut shape: BTreeMap = BTreeMap::new(); + for (i, s) in measured.into_iter().zip(shapes) { + shape.insert(i, s.ok_or_else(|| bad(CMD, "measuring a body failed"))??); + } + + let mut occurrences = Vec::with_capacity(ids.len()); + let mut totals: Vec> = Vec::with_capacity(ids.len()); + for (i, &id) in ids.iter().enumerate() { + let (component, name, world, path, fixed) = if id == 0 { + (0, doc.name.clone(), IDENTITY, Vec::new(), true) + } else { + // Every id came from `doc.occurrences`, so the occurrence is there. + let o = doc.occurrences.iter().find(|o| o.id == id); + let (component, name, grounded) = o.map_or((u64::MAX, String::new(), false), |o| (o.component, o.name.clone(), o.grounded)); + (component, name, joints::occurrence_world(doc, id), canonical_path(doc, id), grounded) + }; + if world.iter().flatten().any(|x| !x.is_finite()) { + return Err(out_of_range()); + } + let (mut bodies, mut parts, mut skipped) = (Vec::new(), Vec::new(), 0); + for (k, body) in state.bodies.iter().enumerate().filter(|(k, _)| owners.get(*k) == Some(&component)) { + let Some(s) = shape.get(&k) else { continue }; + let density = doc.density(&body.name); + let mut entry = BodyMassEntry { + name: body.name.clone(), + material: doc.body_material(&body.name), + density_g_cm3: density, + closed: s.closed, + ..BodyMassEntry::default() + }; + if s.closed { + let at = doc.model_body_offset(body).map_or(world, |offset| mat_mul(&world, offset)); + let l = linear(&at); + let volume = s.volume * det3(&l).abs(); + // g/cm³ × mm³ is mg; kg is 1e-6 of that. + let mass = volume * density * 1e-6; + let inertia = s.gyration.map(|g| inertia::from_gyration(&inertia::transformed(&g, &l), mass)); + let part = MassBody { mass, center: apply_point(&at, s.centroid), inertia }; + if !(part.is_finite() && volume.is_finite()) { + return Err(out_of_range()); + } + if inertia.is_none() { + warnings.push(format!("body `{}`: its mesh does not close, so its inertia is unknown", body.name)); + } + parts.push(part); + entry.volume_mm3 = Some(volume); + entry.mass_kg = Some(mass); + } else { + skipped += 1; + warnings.push(format!("body `{}` is not a closed solid: it adds no mass", body.name)); + } + bodies.push(entry); + } + let total = MassBody::combine(&parts); + if total.is_none() && parts.iter().map(|p| p.mass).sum::() > 0.0 { + return Err(out_of_range()); + } + if total.is_some() && rotation(&world).is_none() { + warnings.push(format!("`{name}` is scaled: its inertia in its own axes is left out")); + } + totals.push(total); + occurrences.push(OccurrenceMass { + occurrence: id, + name, + component, + path, + group: i, + fixed, + frame: frame_of(&world), + bodies, + open_bodies_skipped: skipped, + mass: total.map(|t| rigid_mass(&t, &world)), + }); + } + + // Groups in order of their lowest occurrence id (their union-find root). + let mut by_root: BTreeMap> = BTreeMap::new(); + for i in 0..occurrences.len() { + by_root.entry(groups.root(i)).or_default().push(i); + } + let mut out_groups = Vec::with_capacity(by_root.len()); + for (g, members) in by_root.values().enumerate() { + let fixed = members.iter().any(|&m| occurrences.get(m).is_some_and(|o| o.fixed)); + let frame_occurrence = members.first().and_then(|&m| ids.get(m)).copied().unwrap_or(0); + let world = if frame_occurrence == 0 { IDENTITY } else { joints::occurrence_world(doc, frame_occurrence) }; + let parts: Vec = members.iter().filter_map(|&m| totals.get(m).copied().flatten()).collect(); + let total = MassBody::combine(&parts); + if total.is_none() && !parts.is_empty() { + return Err(out_of_range()); + } + for &m in members { + if let Some(o) = occurrences.get_mut(m) { + o.group = g; + } + } + out_groups.push(RigidGroupMass { + group: g, + occurrences: members.iter().filter_map(|&m| ids.get(m).copied()).collect(), + fixed, + frame_occurrence, + frame: frame_of(&world), + mass: total.map(|t| rigid_mass(&t, &world)), + }); + } + Ok(AssemblyMassResult { occurrences, groups: out_groups, warnings, param_warnings: Vec::new() }) +} + +fn run(s: &mut Session, _p: &Value) -> Result { + let r = s.mass_properties()?; + serde_json::to_value(r).map_err(|e| EngineError::Other(e.to_string())) +} + +#[cfg(test)] +#[path = "mass_properties_tests.rs"] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests; diff --git a/crates/engine/src/cmd/mass_properties_tests.rs b/crates/engine/src/cmd/mass_properties_tests.rs new file mode 100644 index 0000000000..5dc671ed94 --- /dev/null +++ b/crates/engine/src/cmd/mass_properties_tests.rs @@ -0,0 +1,269 @@ +//! `assembly.mass_properties` against closed-form values: a box, a cylinder, two boxes apart +//! (parallel axis), a turned occurrence, a rigid group, and inputs that must not panic. + +use std::f64::consts::PI; + +use serde_json::{Value, json}; + +use super::MassBody; +use crate::Session; +use solidcraft_geom::Vec3; +use solidcraft_geom::inertia::Tensor3 as Tensor; + +fn run(s: &mut Session, cmd: &str, params: Value) -> Value { + s.execute(cmd, ¶ms).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +fn num(v: &Value) -> f64 { + v.as_f64().unwrap_or_else(|| panic!("not a number: {v}")) +} + +fn near(actual: f64, expected: f64, rel: f64) { + assert!((actual - expected).abs() <= rel * expected.abs().max(1e-9), "{actual} != {expected} (rel {rel})"); +} + +fn tensor(v: &Value) -> Tensor { + let mut t = [[0.0; 3]; 3]; + for (i, row) in t.iter_mut().enumerate() { + for (j, cell) in row.iter_mut().enumerate() { + *cell = num(&v[i][j]); + } + } + t +} + +fn diag(t: &Tensor, expected: [f64; 3], rel: f64) { + let scale = expected.iter().fold(0.0f64, |a, b| a.max(b.abs())); + for i in 0..3 { + near(t[i][i], expected[i], rel); + for j in 0..3 { + if i != j { + assert!(t[i][j].abs() <= rel * scale, "off-diagonal [{i}][{j}] = {} in {t:?}", t[i][j]); + } + } + } +} + +/// A new component (not grounded) holding a box; its occurrence id and the body's name. +fn part(s: &mut Session, name: &str, size: [f64; 3], material: &str) -> (u64, u64, String) { + run(s, "component.activate", json!({"component": "root"})); + let c = run(s, "component.create", json!({"name": name, "grounded": false})); + run(s, "solid.box", json!({"length": size[0], "width": size[1], "height": size[2]})); + let body = s.model.state().bodies.last().map(|b| b.name.clone()).unwrap_or_default(); + run(s, "material.assign", json!({"bodies": [body], "material": material})); + (c["component"].as_u64().unwrap_or(0), c["occurrence"].as_u64().unwrap_or(0), body) +} + +fn occurrence(r: &Value, id: u64) -> &Value { + r["occurrences"].as_array().and_then(|a| a.iter().find(|o| o["occurrence"] == id)).unwrap_or_else(|| panic!("no occurrence {id} in {r}")) +} + +/// Box `a × b × c` of mass `m` about its centre: `m/12 (b² + c²)` and so on. +fn box_inertia(m: f64, [a, b, c]: [f64; 3]) -> [f64; 3] { + [m / 12.0 * (b * b + c * c), m / 12.0 * (a * a + c * c), m / 12.0 * (a * a + b * b)] +} + +#[test] +fn a_box_has_its_closed_form_mass_centre_and_inertia() { + let mut s = Session::default(); + let (_, occ, body) = part(&mut s, "Block", [10.0, 20.0, 30.0], "Steel"); + let rho = s.doc.density(&body); + let m = 6000.0 * rho * 1e-6; + let r = run(&mut s, "assembly.mass_properties", json!({})); + let o = occurrence(&r, occ); + assert_eq!(o["bodies"][0]["material"], "Steel"); + assert_eq!(o["bodies"][0]["closed"], true); + near(num(&o["bodies"][0]["volume_mm3"]), 6000.0, 1e-9); + let mass = &o["mass"]; + near(num(&mass["mass_kg"]), m, 1e-9); + for (k, c) in [5.0, 10.0, 15.0].iter().enumerate() { + near(num(&mass["center_of_mass_mm"][k]), *c, 1e-9); + near(num(&mass["center_of_mass_local_mm"][k]), *c, 1e-9); + } + diag(&tensor(&mass["inertia_world_kg_mm2"]), box_inertia(m, [10.0, 20.0, 30.0]), 1e-9); + diag(&tensor(&mass["inertia_local_kg_mm2"]), box_inertia(m, [10.0, 20.0, 30.0]), 1e-9); + // The design itself is a fixed group of its own, without mass. + assert_eq!(r["groups"][0]["occurrences"], json!([0])); + assert_eq!(r["groups"][0]["fixed"], true); + assert!(r["groups"][0]["mass"].is_null()); + let g = &r["groups"][o["group"].as_u64().unwrap_or(0) as usize]; + assert_eq!(g["occurrences"], json!([occ])); + assert_eq!(g["fixed"], false); + near(num(&g["mass"]["mass_kg"]), m, 1e-9); +} + +#[test] +fn a_cylinder_matches_its_closed_form_within_the_tessellation() { + let mut s = Session::default(); + run(&mut s, "component.create", json!({"name": "Shaft", "grounded": false})); + run(&mut s, "solid.cylinder", json!({"radius": 10, "height": 40})); + let body = s.model.state().bodies.last().map(|b| b.name.clone()).unwrap_or_default(); + let rho = s.doc.density(&body); + let (r_, h) = (10.0f64, 40.0f64); + let m = PI * r_ * r_ * h * rho * 1e-6; + let r = run(&mut s, "assembly.mass_properties", json!({})); + let o = &r["occurrences"][1]; + assert_eq!(o["bodies"][0]["material"], s.doc.body_material(&body)); + let mass = &o["mass"]; + near(num(&mass["mass_kg"]), m, 1e-3); + near(num(&mass["center_of_mass_mm"][2]), 20.0, 1e-3); + assert!(num(&mass["center_of_mass_mm"][0]).abs() < 1e-3); + let side = m * (3.0 * r_ * r_ + h * h) / 12.0; + diag(&tensor(&mass["inertia_world_kg_mm2"]), [side, side, m * r_ * r_ / 2.0], 2e-3); +} + +#[test] +fn two_boxes_apart_combine_by_the_parallel_axis_theorem() { + let mut s = Session::default(); + let (_, occ, body) = part(&mut s, "Pair", [10.0, 10.0, 10.0], "Aluminum"); + run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [30, 0, 0]})); + let second = s.model.state().bodies.last().map(|b| b.name.clone()).unwrap_or_default(); + assert_ne!(second, body); + let (r1, r2) = (s.doc.density(&body), s.doc.density(&second)); + let (m1, m2) = (1000.0 * r1 * 1e-6, 1000.0 * r2 * 1e-6); + let m = m1 + m2; + // Centres at x = 5 and x = 35. + let cx = (5.0 * m1 + 35.0 * m2) / m; + let r = run(&mut s, "assembly.mass_properties", json!({})); + let mass = &occurrence(&r, occ)["mass"]; + near(num(&mass["mass_kg"]), m, 1e-9); + near(num(&mass["center_of_mass_mm"][0]), cx, 1e-9); + let own = |mi: f64| mi / 12.0 * 200.0; + let shift = m1 * (cx - 5.0).powi(2) + m2 * (35.0 - cx).powi(2); + diag(&tensor(&mass["inertia_world_kg_mm2"]), [own(m1) + own(m2), own(m1) + own(m2) + shift, own(m1) + own(m2) + shift], 1e-9); +} + +#[test] +fn a_turned_occurrence_turns_its_world_tensor_but_not_its_own() { + let mut s = Session::default(); + let (_, occ, body) = part(&mut s, "Block", [10.0, 20.0, 30.0], "Steel"); + let m = 6000.0 * s.doc.density(&body) * 1e-6; + // A quarter turn about z, kept pending (pending moves count, as Properties shows them). + run(&mut s, "occurrence.move", json!({"occurrence": occ, "axis": [0, 0, 1], "angle": "90 deg", "translate": [100, 0, 0], "capture": false})); + let r = run(&mut s, "assembly.mass_properties", json!({})); + let o = occurrence(&r, occ); + let frame = &o["frame"]; + near(num(&frame["x"][1]), 1.0, 1e-9); + near(num(&frame["y"][0]), -1.0, 1e-9); + let mass = &o["mass"]; + let local = box_inertia(m, [10.0, 20.0, 30.0]); + diag(&tensor(&mass["inertia_world_kg_mm2"]), [local[1], local[0], local[2]], 1e-9); + diag(&tensor(&mass["inertia_local_kg_mm2"]), local, 1e-9); + for (k, c) in [5.0, 10.0, 15.0].iter().enumerate() { + near(num(&mass["center_of_mass_local_mm"][k]), *c, 1e-9); + } + // World centre: (5, 10, 15) turned about z then moved. + let world: Vec = (0..3).map(|k| num(&mass["center_of_mass_mm"][k])).collect(); + let expected = [num(&frame["origin"][0]) - 10.0, num(&frame["origin"][1]) + 5.0, num(&frame["origin"][2]) + 15.0]; + for (a, e) in world.iter().zip(expected) { + near(*a, e, 1e-9); + } +} + +#[test] +fn a_rigid_group_is_one_body_and_other_joints_keep_bodies_apart() { + let mut s = Session::default(); + let (_, a, body_a) = part(&mut s, "Base", [10.0, 10.0, 10.0], "Steel"); + let (_, b, body_b) = part(&mut s, "Cap", [10.0, 10.0, 10.0], "Steel"); + run(&mut s, "occurrence.move", json!({"occurrence": b, "translate": [0, 0, 40]})); + let (_, c, _) = part(&mut s, "Loose", [10.0, 10.0, 10.0], "Steel"); + run(&mut s, "occurrence.move", json!({"occurrence": c, "translate": [100, 0, 0]})); + run(&mut s, "joint.rigid_group", json!({"occurrences": [a, b]})); + let r = run(&mut s, "assembly.mass_properties", json!({})); + let (ga, gb, gc) = (occurrence(&r, a)["group"].clone(), occurrence(&r, b)["group"].clone(), occurrence(&r, c)["group"].clone()); + assert_eq!(ga, gb); + assert_ne!(ga, gc); + let g = &r["groups"][ga.as_u64().unwrap_or(0) as usize]; + let mut held: Vec = vec![a, b]; + held.sort_unstable(); + assert_eq!(g["occurrences"], json!(held)); + assert_eq!(g["frame_occurrence"], json!(held[0])); + let m1 = 1000.0 * s.doc.density(&body_a) * 1e-6; + let m2 = 1000.0 * s.doc.density(&body_b) * 1e-6; + let m = m1 + m2; + let mass = &g["mass"]; + near(num(&mass["mass_kg"]), m, 1e-9); + // Centres at z = 5 and z = 45: the common one halfway, each 20 mm off it. + near(num(&mass["center_of_mass_mm"][2]), 25.0, 1e-9); + let own = m1 / 12.0 * 200.0 + m2 / 12.0 * 200.0; + let shift = m1 * 400.0 + m2 * 400.0; + diag(&tensor(&mass["inertia_world_kg_mm2"]), [own + shift, own + shift, own], 1e-9); +} + +#[test] +fn a_nested_occurrence_no_joint_places_moves_with_its_parent() { + let mut s = Session::default(); + let outer = run(&mut s, "component.create", json!({"name": "Assembly", "grounded": false})); + let inner = run(&mut s, "component.create", json!({"name": "Inner", "grounded": false})); + run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10})); + let r = run(&mut s, "assembly.mass_properties", json!({})); + let (o, i) = (outer["occurrence"].as_u64().unwrap_or(0), inner["occurrence"].as_u64().unwrap_or(0)); + assert_eq!(occurrence(&r, o)["group"], occurrence(&r, i)["group"]); + assert!(occurrence(&r, o)["mass"].is_null(), "the outer component has no bodies of its own"); + assert_eq!(occurrence(&r, i)["path"], json!([o, i])); +} + +#[test] +fn hostile_and_degenerate_inputs_never_panic() { + // An empty design: the design itself, without mass. + let mut s = Session::default(); + let r = run(&mut s, "assembly.mass_properties", json!({})); + assert_eq!(r["occurrences"].as_array().map(Vec::len), Some(1)); + assert!(r["occurrences"][0]["mass"].is_null()); + // A pending placement that is not finite is refused, not propagated. + let (_, occ, _) = part(&mut s, "Block", [10.0, 10.0, 10.0], "Steel"); + let mut bad = solidcraft_doc::IDENTITY; + bad[3][0] = f64::NAN; + s.pending_moves.insert(occ, bad); + assert!(s.execute("assembly.mass_properties", &json!({})).is_err()); + let mut huge = solidcraft_doc::IDENTITY; + huge[0][0] = 1e200; + huge[1][1] = 1e200; + huge[2][2] = 1e200; + s.pending_moves.insert(occ, huge); + assert!(s.execute("assembly.mass_properties", &json!({})).is_err()); + // A flattening placement: no volume, so no mass, and no panic. + let mut flat = solidcraft_doc::IDENTITY; + flat[2][2] = 0.0; + s.pending_moves.insert(occ, flat); + let r = run(&mut s, "assembly.mass_properties", json!({})); + assert!(occurrence(&r, occ)["mass"].is_null(), "{r}"); + // A rigid joint to an occurrence that is gone is ignored. + s.pending_moves.clear(); + let mut doc = (*s.doc).clone(); + if let Some(j) = doc.assembly.joints.first_mut() { + j.b.occurrence = 999_999; + } + s.doc = std::sync::Arc::new(doc); + run(&mut s, "assembly.mass_properties", json!({})); + + // The combination itself. + assert_eq!(MassBody::combine(&[]), None); + let neg = MassBody { mass: -1.0, ..MassBody::default() }; + assert_eq!(MassBody::combine(&[neg]), None); + let nan = MassBody { mass: 1.0, center: Vec3::new(f64::NAN, 0.0, 0.0), ..MassBody::default() }; + assert_eq!(MassBody::combine(&[nan]), None); + let inf = MassBody { mass: f64::INFINITY, ..MassBody::default() }; + assert_eq!(MassBody::combine(&[inf]), None); + // A part whose tensor is unknown leaves the total's unknown, not wrong. + let known = MassBody { mass: 1.0, center: Vec3::ZERO, inertia: Some([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]) }; + let unknown = MassBody { mass: 1.0, center: Vec3::new(2.0, 0.0, 0.0), inertia: None }; + let both = MassBody::combine(&[known, unknown]).unwrap_or_else(|| panic!("positive mass")); + assert_eq!(both.inertia, None); + near(both.center.x, 1.0, 1e-12); +} + +#[test] +fn combining_two_point_like_bodies_adds_the_parallel_axis_terms() { + let unit: Tensor = [[1.0, 0.0, 0.0], [0.0, 2.0, 0.0], [0.0, 0.0, 3.0]]; + let a = MassBody { mass: 1.0, center: Vec3::new(0.0, 0.0, 0.0), inertia: Some(unit) }; + let b = MassBody { mass: 3.0, center: Vec3::new(4.0, 4.0, 0.0), inertia: Some(unit) }; + let t = MassBody::combine(&[a, b]).and_then(|t| t.inertia).unwrap_or_else(|| panic!("combines")); + // Centre (3, 3, 0); a is (−3, −3) from it, b (1, 1): Σ m d² terms and −Σ m dx dy. + let (sxx, sxy) = (1.0 * 9.0 + 3.0 * 1.0, 1.0 * 9.0 + 3.0 * 1.0); + near(t[0][0], 2.0 + sxx, 1e-12); + near(t[1][1], 4.0 + sxx, 1e-12); + near(t[2][2], 6.0 + 2.0 * sxx, 1e-12); + near(t[0][1], -sxy, 1e-12); + near(t[1][0], -sxy, 1e-12); +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 10f9990469..cf228ea4a3 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -46,6 +46,7 @@ pub use join_pieces::{Addition, addition}; pub use sketch::dimension_roles; pub(crate) use sketch::face::face_pick_form; pub use sketch_construction::{ConstructionLine, construction_line_choice, construction_line_mode, construction_line_solutions}; +pub use sketch_create::tangent_arc_preview; pub use sketch_import::drawing_import_command; pub use sketch_modify::{extend_arc_preview, extend_preview, trim_piece}; pub use view_dxf::sketch::exportable as view_dxf_exportable; @@ -60,6 +61,7 @@ pub(crate) mod inspect; mod interference; pub(crate) mod joints; mod libraries; +pub mod mass_properties; mod material_properties; mod materials; mod measure_sel; @@ -92,6 +94,7 @@ pub(crate) use sheet::{ mod silhouette; mod sketch; pub(crate) use sketch::author_path_for_rebind; +pub(crate) use sketch::collapse_reason; pub(crate) use sketch::created as sketch_created; mod canvas_sketch; mod sketch_3d; @@ -325,6 +328,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> { v.extend(config::COMMANDS.iter()); v.extend(parts::COMMANDS.iter()); v.extend(bom::COMMANDS.iter()); + v.extend(mass_properties::COMMANDS.iter()); v.extend(browser::COMMANDS.iter()); v.extend(delete::COMMANDS.iter()); v.extend(edit::COMMANDS.iter()); @@ -383,6 +387,20 @@ pub(crate) fn did_you_mean(id: &str) -> String { } } +/// "no body `name`", and the closest of `bodies` when it is near enough to be a typo or another +/// case of the same name (`b` for `B`): `no body `Bdy`; did you mean `Body`?` (#9740). +pub(crate) fn no_body<'a>(name: &str, bodies: impl IntoIterator) -> String { + let want = name.to_lowercase(); + let near = (want.chars().count() / 3).max(1); + let closest = (want.len() <= 256) + .then(|| bodies.into_iter().map(|b| (edit_distance(&want, &b.to_lowercase()), b)).filter(|(d, _)| *d <= near).min_by_key(|(d, _)| *d)) + .flatten(); + match closest { + Some((_, b)) => format!("no body `{name}`; did you mean `{b}`?"), + None => format!("no body `{name}`"), + } +} + /// Levenshtein distance: the single-character edits that turn `a` into `b`. pub(crate) fn edit_distance(a: &str, b: &str) -> usize { let b: Vec = b.chars().collect(); diff --git a/crates/engine/src/cmd/motion.rs b/crates/engine/src/cmd/motion.rs index 0e1e65a922..f1c5b74486 100644 --- a/crates/engine/src/cmd/motion.rs +++ b/crates/engine/src/cmd/motion.rs @@ -103,7 +103,7 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("joint.sweep", "Sweep Joint", sweep::sweep).icon("interference").params(sweep::SWEEP_DOC), CommandSpec::new("motion.list", "List Motion Studies", list_studies).icon("list").noundo(), CommandSpec::new("motion.delete", "Delete Motion Study", delete_study).icon("delete").params("study"), - CommandSpec::new("explode.create", "Exploded View", explode_create).at("SOLID", "ASSEMBLE").icon("explode").params( + CommandSpec::new("explode.create", "Exploded View", explode_create).at("SOLID", "ASSEMBLE").icon("explode").enabled(has_parts_to_explode).params( "name?; scale? (automatic: each occurrence pushed away from the assembly's centre by scale × its offset, default 1); \ moves?: [{occurrence, translate: [x,y,z]}] (instead of automatic)", ), @@ -117,6 +117,14 @@ pub static COMMANDS: &[CommandSpec] = &[ const MAX_ITEMS: usize = 10_000; +/// `prefix` and the first number from 1 that no name in `taken` has: a default name never repeats +/// one in use, after a delete either, as joints' and the app's dialogs' default names (#9738). +fn free_name<'a>(prefix: &str, taken: impl Iterator) -> String { + let taken: BTreeSet<&str> = taken.collect(); + let n = (1..=taken.len() + 1).find(|n| !taken.contains(format!("{prefix}{n}").as_str())).unwrap_or(1); + format!("{prefix}{n}") +} + fn occurrences(s: &Session, p: &Value, cmd: &str) -> Result> { let list = p.get("occurrences").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`occurrences` must list occurrences"))?; if list.len() > MAX_ITEMS { @@ -496,7 +504,8 @@ fn new_set(s: &mut Session, p: &Value) -> Result { return Err(bad(cmd, "too many contact sets")); } let id = s.doc_mut().allocate_id()?; - let name = str_(p, "name").map(str::to_string).unwrap_or_else(|| format!("Contact Set{}", s.doc.assembly.contact_sets.len() + 1)); + let name = + str_(p, "name").map(str::to_string).unwrap_or_else(|| free_name("Contact Set", s.doc.assembly.contact_sets.iter().map(|c| c.name.as_str()))); let a = &mut s.doc_mut().assembly; a.contact_sets.push(ContactSet { id, name: name.clone(), occurrences: occ, suppressed: false }); a.contact_enabled = true; @@ -629,7 +638,7 @@ fn study(s: &mut Session, p: &Value) -> Result { } let m = MotionStudy { id, - name: str_(p, "name").map(str::to_string).unwrap_or_else(|| format!("Motion Study{}", count + 1)), + name: str_(p, "name").map(str::to_string).unwrap_or_else(|| free_name("Motion Study", a.studies.iter().map(|m| m.name.as_str()))), steps: steps.unwrap_or(100), keys, }; @@ -1013,6 +1022,16 @@ fn delete_study(s: &mut Session, p: &Value) -> Result { // ---- Exploded views ---- +/// Why Exploded View is greyed out: it pulls components apart, so a design of plain bodies, or +/// with a single component, has nothing to separate (#9142). +const EXPLODE_NEEDS_COMPONENTS: &str = + "Exploded View pulls components apart, so the design needs two or more of them (New Component, Insert Design or Create Components from Bodies)"; + +/// Exploded View is offered once the design has two or more occurrences to move apart (#9142). +fn has_parts_to_explode(s: &Session) -> std::result::Result<(), String> { + if s.doc.occurrences.len() >= 2 { Ok(()) } else { Err(EXPLODE_NEEDS_COMPONENTS.into()) } +} + fn view_index(s: &Session, p: &Value, cmd: &str) -> Result { let key = match p.get("view") { Some(Value::Number(n)) => n.to_string(), @@ -1077,7 +1096,8 @@ fn explode_create(s: &mut Session, p: &Value) -> Result { return Err(bad(cmd, "too many exploded views")); } let id = s.doc_mut().allocate_id()?; - let name = str_(p, "name").map(str::to_string).unwrap_or_else(|| format!("Exploded View{}", s.doc.assembly.exploded.len() + 1)); + let name = + str_(p, "name").map(str::to_string).unwrap_or_else(|| free_name("Exploded View", s.doc.assembly.exploded.iter().map(|v| v.name.as_str()))); s.doc_mut().assembly.exploded.push(ExplodedView { id, name: name.clone(), moves: moves.clone() }); Ok(json!({"view": id, "name": name, "moves": moves.iter().map(|(o, t)| json!({"occurrence": o, "translate": t})).collect::>()})) } diff --git a/crates/engine/src/cmd/motion_tests.rs b/crates/engine/src/cmd/motion_tests.rs index 558dbb48d9..2b60116de7 100644 --- a/crates/engine/src/cmd/motion_tests.rs +++ b/crates/engine/src/cmd/motion_tests.rs @@ -38,6 +38,46 @@ fn deleted_motion_item_ids_are_not_reused_after_reopening() { } } +/// #9738: a default name skips the names in use, after a delete too: contact sets, motion +/// studies, exploded views, and the placements of a component. +#[test] +fn default_names_skip_names_in_use() { + for (create, delete, key, params, prefix) in [ + ("contact.create", "contact.edit", "set", json!({"occurrences": ["A", "B"]}), "Contact Set"), + ("motion.study", "motion.delete", "study", json!({"steps": 10}), "Motion Study"), + ("explode.create", "explode.delete", "view", json!({"moves": [{"occurrence": "B", "translate": [20, 0, 0]}]}), "Exploded View"), + ] { + let mut s = Session::default(); + for name in ["A", "B"] { + run(&mut s, "component.activate", json!({"component": "root"})); + run(&mut s, "component.create", json!({"name": name})); + } + let made = |s: &mut Session| { + let r = run(s, create, params.clone()); + (r[key].as_u64().unwrap(), r["name"].as_str().unwrap().to_string()) + }; + let (first, one) = made(&mut s); + let (_, two) = made(&mut s); + assert_eq!([one, two], [format!("{prefix}1"), format!("{prefix}2")]); + run(&mut s, delete, json!({key: first, "delete": true})); + assert_eq!(made(&mut s).1, format!("{prefix}1"), "{create}: the free name, not {prefix}2 again"); + } + // Placements: with the first deleted, a copy takes a name none has. + let mut s = Session::default(); + run(&mut s, "component.create", json!({"name": "Arm"})); + run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10})); + run(&mut s, "component.activate", json!({"component": "root"})); + let arm = occ(&s, "Arm"); + for _ in 0..2 { + run(&mut s, "occurrence.copy", json!({"occurrence": arm, "translate": [20, 0, 0]})); + } + let names = |s: &Session| s.doc.occurrences.iter().map(|o| o.name.clone()).collect::>(); + assert_eq!(names(&s), ["Arm:1", "Arm:2", "Arm:3"]); + run(&mut s, "selection.delete", json!({"occurrences": ["Arm:1"]})); + run(&mut s, "occurrence.copy", json!({"occurrence": "Arm:3", "translate": [20, 0, 0]})); + assert_eq!(names(&s), ["Arm:2", "Arm:3", "Arm:1"]); +} + /// Where B's corner (20, 0, 0) is now. fn b_x(s: &Session) -> f64 { super::world_point(s, occ(s, "B"), Vec3::new(20.0, 0.0, 0.0)).x @@ -691,6 +731,33 @@ fn exploded_views_show_without_changing_the_design() { assert_eq!(run(&mut s, "explode.list", json!({}))["views"].as_array().map(Vec::len), Some(2)); } +/// #9142: on a part of plain bodies (the sample plate) Exploded View opened its dialog and failed +/// on OK ("an exploded view needs two or more occurrences"). It is greyed out instead, saying why, +/// until the design has two components to pull apart. +#[test] +fn exploded_view_is_greyed_out_until_there_are_two_components() { + let spec = crate::cmd::find_command("explode.create").unwrap(); + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Plate"})); + let info = spec.info(&s); + assert!(!info.enabled, "a part of plain bodies has nothing to explode"); + assert_eq!(info.disabled_reason.as_deref(), Some(super::EXPLODE_NEEDS_COMPONENTS)); + let e = s.execute("explode.create", &json!({"scale": 1.62})).unwrap_err(); + assert!(e.to_string().contains("two or more of them"), "{e}"); + assert!(s.doc.assembly.exploded.is_empty(), "nothing was added: {e}"); + // One component is still nothing to pull apart. + run(&mut s, "component.from_bodies", json!({"bodies": ["Plate"]})); + assert_eq!(s.doc.occurrences.len(), 1); + assert!(!spec.info(&s).enabled); + // A second one brings it back, and the automatic view separates the two. + run(&mut s, "component.activate", json!({"component": "root"})); + run(&mut s, "component.create", json!({"name": "Lid"})); + run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [30, 0, 0]})); + assert!(spec.info(&s).enabled, "{:?}", spec.info(&s).disabled_reason); + let r = run(&mut s, "explode.create", json!({"scale": 1.62})); + assert_eq!(r["moves"].as_array().map(Vec::len), Some(2), "{r}"); +} + /// Pictures drawn by the CPU rasterizer (the engine has no GPU): real views of the scene. fn cpu(scene: &crate::render::Scene, cam: &crate::render::Camera, w: u32, h: u32) -> Result { Ok(crate::capture::Picture { rgba: crate::render::render(scene, cam, w as usize, h as usize).rgba, renderer: "cpu" }) diff --git a/crates/engine/src/cmd/occurrence_tests.rs b/crates/engine/src/cmd/occurrence_tests.rs index e259e017b9..18dc2bff43 100644 --- a/crates/engine/src/cmd/occurrence_tests.rs +++ b/crates/engine/src/cmd/occurrence_tests.rs @@ -101,6 +101,38 @@ fn renaming_a_parent_preserves_hidden_nested_copies_with_ambiguous_labels() { } } +#[test] +fn body_rename_reusing_hidden_names_preserves_each_copy() { + let mut s = Session::default(); + let (component, first) = created(&mut s, "Part"); + let retired = run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "body_name": "Retired"}))["feature"].as_u64().unwrap(); + run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [10, 0, 0], "body_name": "Visible"})); + let second = run(&mut s, "occurrence.copy", json!({"component": component, "translate": [20, 0, 0]}))["occurrence"].as_u64().unwrap(); + let name = |s: &Session, source: &str, path: &[u64]| { + s.occurrence_placement(path).unwrap().bodies.iter().find(|b| b.source == source).unwrap().world.clone() + }; + let retired_names = [name(&s, "Retired", &[first]), name(&s, "Retired", &[second])]; + run(&mut s, "browser.visibility", json!({"items": retired_names, "visible": false})); + run(&mut s, "timeline.delete", json!({"feature": retired})); + let hidden = name(&s, "Visible", &[second]); + run(&mut s, "browser.visibility", json!({"items": [hidden], "visible": false})); + let before = s.visibility.clone(); + for (command, params, source) in [ + ("body.rename", json!({"body": "Visible", "name": "Retired"}), "Retired"), + ("edit.undo", json!({}), "Visible"), + ("edit.redo", json!({}), "Retired"), + ] { + run(&mut s, command, params); + assert!(s.body_visible(&name(&s, source, &[first])), "{command}: the first copy stays visible"); + assert!(!s.body_visible(&name(&s, source, &[second])), "{command}: the second copy stays hidden"); + if command == "edit.undo" { + assert_eq!(s.visibility, before); + } else { + assert_eq!(s.visibility.hidden_bodies, [name(&s, source, &[second])]); + } + } +} + /// The world name of a placed body as `Document::world_body_name` worked it out before /// `WorldNames` (each name scanning the whole design), kept to prove the two agree. fn world_name_by_scanning(doc: &crate::doc::Document, st: &crate::doc::ModelState, source: &str, component: u64, path: &[u64]) -> String { diff --git a/crates/engine/src/cmd/parameters.rs b/crates/engine/src/cmd/parameters.rs index 3f22f176bf..d8b89d0e5e 100644 --- a/crates/engine/src/cmd/parameters.rs +++ b/crates/engine/src/cmd/parameters.rs @@ -230,7 +230,18 @@ fn keep_dimension_start(s: &mut Session, name: &str) { } let reference: Vec<_> = stored.points.iter().map(|p| p.pos).collect(); let hold = Hold { points: vec![start], radii: Vec::new() }; - if !solve_with(&mut trial, &hold, Some(&reference), Analysis::StatusOnly, None).keeps_orientation() { + let solved = if trial.offsets.is_empty() && trial.freeform_offsets.is_empty() { + solve_with(&mut trial, &hold, Some(&reference), Analysis::StatusOnly, None).keeps_orientation() + } else { + // A solve against stale offset points can stretch an attached cap into a different + // tangency branch. Keep the start through the complete solve/refresh sequence. + let Some(p) = trial.points.get_mut(start) else { continue }; + p.fixed = true; + s.doc + .solve_sketch_with_analysis(&vals, &mut trial, &stored.driving_values(), Some(stored), Analysis::StatusOnly) + .is_ok_and(|report| report.keeps_orientation()) + }; + if !solved { continue; } if stored.points.len() == trial.points.len() { diff --git a/crates/engine/src/cmd/parameters_tests.rs b/crates/engine/src/cmd/parameters_tests.rs index 3c1fe01e16..04642c6bc6 100644 --- a/crates/engine/src/cmd/parameters_tests.rs +++ b/crates/engine/src/cmd/parameters_tests.rs @@ -326,6 +326,43 @@ fn csv_and_json_round_trip() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn csv_round_trips_comments_with_line_breaks() { + for comment in ["line one\nline two", "\r\nfirst, \"quoted\"\r\n\r\nlast\r\n", "one\rtwo"] { + let mut s = Session::default(); + run(&mut s, "parameters.add", json!({"name": "L", "expression": "30", "comment": comment})); + run(&mut s, "parameters.add", json!({"name": "W", "expression": "10"})); + let exported = run(&mut s, "parameters.export", json!({"format": "csv"})); + + let mut fresh = Session::default(); + let imported = run(&mut fresh, "parameters.import", json!({"text": exported["text"]})); + assert_eq!(imported["imported"], json!(["L", "W"])); + let list = run(&mut fresh, "parameters.list", json!({})); + assert_eq!(row(&list, "L")["comment"], comment); + assert_eq!(row(&list, "L")["value"], 30.0); + assert_eq!(row(&list, "W")["value"], 10.0); + } +} + +#[test] +fn csv_import_accepts_a_bom_before_the_header() { + let mut s = Session::default(); + let csv = "\u{feff}\"NAME\",expression\r\n\r\nA,5"; + let imported = run(&mut s, "parameters.import", json!({"text": csv})); + assert_eq!(imported["imported"], json!(["A"])); + assert_eq!(row(&run(&mut s, "parameters.list", json!({})), "A")["value"], 5.0); +} + +#[test] +fn csv_import_with_an_unclosed_quote_leaves_parameters_alone() { + let mut s = Session::default(); + run(&mut s, "parameters.add", json!({"name": "L", "expression": "10"})); + let before = s.doc.to_json(); + let csv = "name,expression,comment\nL,30,updated\nW,5,\"unfinished\n"; + assert!(s.execute("parameters.import", &json!({"text": csv})).is_err()); + assert_eq!(s.doc.to_json(), before); +} + /// #8960: the text marker accepts ASCII case variants without changing numeric units. #[test] fn text_parameter_imports_accept_mixed_case_units() { @@ -1108,3 +1145,53 @@ fn a_feature_on_a_failed_sketch_names_the_sketch() { assert_eq!(r["errors"], json!([]), "{r}"); assert!(rel(volume(&mut s), 40.0 * 20.0 * 5.0 + 500.0) < 1e-9); } + +/// A finished sketch with one line (0,0)-(30,0) whose length is dimensioned to parameter `L`. +fn line_dimensioned_by_a_parameter() -> (Session, u64) { + let mut s = Session::default(); + run(&mut s, "parameters.add", json!({"name": "L", "expression": "30"})); + run(&mut s, "sketch.create", json!({"plane": "XY"})); + run(&mut s, "sketch.line", json!({"points": [[0, 0], [30, 0]]})); + run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": "L"})); + run(&mut s, "sketch.finish", json!({})); + let feature = s.doc.find_feature("Sketch1").map(|f| f.id).unwrap_or_default(); + (s, feature) +} + +/// #9729: `sketch.dimension` refuses 0 ("that would shrink l1 to a point") but a value edit +/// let it through: the line collapsed to a point and every later value counted as a conflict, +/// so only Undo recovered — and after a save and reopen there is no Undo. A value edit now +/// runs the same shrink/fling checks `settle` runs, so the zero is refused where it happens. +#[test] +fn a_parameter_that_shrinks_a_dimensioned_line_to_a_point_is_refused() { + let (mut s, feature) = line_dimensioned_by_a_parameter(); + let e = err(&mut s, "parameters.change", json!({"name": "L", "expression": "0"})); + assert!(e.contains("shrink l1 to a point") && e.contains("the values were not changed"), "{e}"); + // The line is where it was and the sketch still solves. + assert!((far_x(&s, feature) - 30.0).abs() < 1e-6); + assert!(s.model.state().sketch(feature).unwrap().report.keeps_orientation()); + // Other values still work, in both directions: the sketch was never stored collapsed. + run(&mut s, "parameters.change", json!({"name": "L", "expression": "20"})); + assert!((far_x(&s, feature) - 20.0).abs() < 1e-6); + run(&mut s, "parameters.change", json!({"name": "L", "expression": "30"})); + assert!((far_x(&s, feature) - 30.0).abs() < 1e-6); +} + +/// The largest x among the stored sketch's points: the line's far end. +fn far_x(s: &Session, feature: u64) -> f64 { + stored_points(s, feature).iter().map(|p| p.x).fold(f64::MIN, f64::max) +} + +/// The same through a configuration row: activating a row whose value collapses the line is +/// refused, and the Default row can still be activated afterwards. +#[test] +fn a_configuration_row_that_shrinks_a_dimensioned_line_to_a_point_is_refused() { + let (mut s, feature) = line_dimensioned_by_a_parameter(); + run(&mut s, "config.configure", json!({"params": ["L"]})); + run(&mut s, "config.row", json!({"name": "Zero", "values": {"param:L": "0"}})); + let e = err(&mut s, "config.activate", json!({"row": "Zero"})); + assert!(e.contains("shrink l1 to a point"), "{e}"); + // The Default row still activates and the line is untouched. + run(&mut s, "config.activate", json!({"row": "Default"})); + assert!((far_x(&s, feature) - 30.0).abs() < 1e-6); +} diff --git a/crates/engine/src/cmd/pbr_appearance_tests.rs b/crates/engine/src/cmd/pbr_appearance_tests.rs index 1d16a05ed9..16641deed2 100644 --- a/crates/engine/src/cmd/pbr_appearance_tests.rs +++ b/crates/engine/src/cmd/pbr_appearance_tests.rs @@ -945,3 +945,38 @@ fn appearance_gestures_favorite_is_a_validated_transient_request() { assert!(Arc::ptr_eq(&before, &s.doc)); assert_eq!((s.undo.len(), s.redo.len(), s.revision, s.evaluations, s.is_dirty()), state); } + +/// A placed copy's name (`KB (K:2)`, the name inspect.measure lists) and a placement's (`K:2`) +/// mean the body and component they show in appearance.chain, appearance.list and +/// appearance.replace's scope, as appearance.assign already takes them (#9740). +#[test] +fn appearance_reads_and_scopes_take_a_placed_copys_name() { + let mut s = Session::default(); + s.execute("component.create", &json!({"name":"K"})).unwrap(); + s.execute("solid.box", &json!({"body_name":"KB","length":10,"width":10,"height":10})).unwrap(); + s.execute("occurrence.copy", &json!({"component":"K","translate":[30,0,0]})).unwrap(); + let world: Vec = s.world_state().bodies.iter().map(|b| b.name.clone()).collect(); + assert!(world.iter().any(|n| n == "KB (K:2)"), "{world:?}"); + s.execute("appearance.assign", &json!({"bodies":["KB"],"appearance":"ABS - Red"})).unwrap(); + s.execute("appearance.assign", &json!({"components":["K"],"appearance":"ABS - Blue"})).unwrap(); + + assert_eq!(chain_read(&mut s, json!({"body":"KB (K:2)"})), chain_read(&mut s, json!({"body":"KB"}))); + assert_eq!(chain_read(&mut s, json!({"body":"KB (K:2)","face":0})), chain_read(&mut s, json!({"body":"KB","face":0}))); + assert_eq!(chain_read(&mut s, json!({"component":"K:2"})), chain_read(&mut s, json!({"component":"K"}))); + let listed = |s: &mut Session, body: &str| s.execute("appearance.list", &json!({"body":body})).unwrap()["body"].clone(); + let placed = listed(&mut s, "KB (K:2)"); + assert_eq!(placed["name"], "KB", "the reply names the body the copy shows: {placed}"); + assert_eq!(placed, listed(&mut s, "KB")); + + let scope = json!({"bodies":["KB (K:2)"],"components":["K:2"]}); + let r = s.execute("appearance.replace", &json!({"from":"ABS - Red","to":"ABS - Blue","scope":scope})).unwrap(); + assert_eq!(r["count"], 1, "{r}"); + assert_eq!(s.doc.appearances.bodies["KB"], s.doc.appearances.components[&s.doc.find_component("K").unwrap()]); + + // A name that is neither the model's nor a placed copy's is still refused. + assert!(s.execute("appearance.chain", &json!({"target":{"body":"KB (K:9)"}})).is_err()); + assert!(s.execute("appearance.chain", &json!({"target":{"component":"K:9"}})).is_err()); + assert!(s.execute("appearance.list", &json!({"body":"KB (K:9)"})).is_err()); + assert!(s.execute("appearance.replace", &json!({"from":"ABS - Blue","to":"ABS - Red","scope":{"bodies":["KB (K:9)"]}})).is_err()); + assert!(s.execute("appearance.replace", &json!({"from":"ABS - Blue","to":"ABS - Red","scope":{"components":["K:9"]}})).is_err()); +} diff --git a/crates/engine/src/cmd/script.rs b/crates/engine/src/cmd/script.rs index d67c330955..d2bd90779c 100644 --- a/crates/engine/src/cmd/script.rs +++ b/crates/engine/src/cmd/script.rs @@ -21,7 +21,7 @@ pub static COMMANDS: &[CommandSpec] = &[ ), CommandSpec::new("timeline.export_script", "Export Command Script", export_script).icon("export").noundo().doc_only().params( "path?: write the script to this file → script: {commands: [...]} that rebuilds the design (solidcraft-cli eval/run), \ - steps, omitted: what the script leaves out (appearances, folders, views, configurations); components and assemblies \ + steps, omitted: what the script leaves out (appearances, folders, views, configurations, print-face assignments); components and assemblies \ are not supported yet", ), ]; @@ -198,6 +198,7 @@ pub(crate) fn script_steps(doc: &Document) -> Result<(Vec, Vec)> (doc.configs.is_empty(), "configurations"), (doc.canvases.is_empty(), "canvases (reference images)"), (doc.point_clouds.is_empty(), "point clouds"), + (doc.print_faces.is_empty(), "print-face assignments (body.print_face)"), (doc.named_views.is_empty(), "named views"), (doc.browser_groups.is_empty() && doc.browser_order.is_empty(), "Browser folders and order"), (doc.timeline_groups.is_empty(), "timeline folders"), diff --git a/crates/engine/src/cmd/script_tests.rs b/crates/engine/src/cmd/script_tests.rs index 1528d61743..75ff02d33f 100644 --- a/crates/engine/src/cmd/script_tests.rs +++ b/crates/engine/src/cmd/script_tests.rs @@ -170,6 +170,25 @@ fn what_the_script_leaves_out_is_listed_and_components_are_refused() { assert!(e.contains("not supported yet"), "{e}"); } +#[test] +fn omitted_print_faces_are_reported_without_changing_the_design() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 20, "width": 10, "height": 5, "body_name": "Block"})); + assert_eq!(run(&mut s, "timeline.export_script", json!({}))["omitted"], json!([])); + + run(&mut s, "body.print_face", json!({"body": "Block", "point": [10, 5, 0]})); + let before = (*s.doc).clone(); + let r = run(&mut s, "timeline.export_script", json!({})); + assert_eq!(r["omitted"], json!(["print-face assignments (body.print_face)"])); + assert_eq!(*s.doc, before, "export must keep the saved print-face assignment"); + let mut t = replay(&r["script"]); + assert!(t.doc.print_faces.is_empty(), "the reported omission matches the replay"); + same(&mut s, &mut t); + + run(&mut s, "body.print_face", json!({"body": "Block"})); + assert_eq!(run(&mut s, "timeline.export_script", json!({}))["omitted"], json!([])); +} + #[test] fn a_recorded_feature_that_does_not_fit_is_refused_and_changes_nothing() { let mut s = Session::default(); diff --git a/crates/engine/src/cmd/section.rs b/crates/engine/src/cmd/section.rs index 80cc6d62da..ebae74fa2d 100644 --- a/crates/engine/src/cmd/section.rs +++ b/crates/engine/src/cmd/section.rs @@ -1,15 +1,19 @@ //! Section Analysis: a view-only cut through the model by a plane (what is on the plane's //! positive side is hidden). It changes no geometry and is not an undo step. -use serde_json::{Value, json}; +use serde_json::Value; use solidcraft_geom::{Plane, Vec3}; use super::CommandSpec; use crate::params::{bad, bool_, expr, vec3}; +use crate::schema::{TypedContract, output_schema_for, section as contract}; use crate::{Result, Session}; +const SECTION: TypedContract = TypedContract::new(); pub static COMMANDS: &[CommandSpec] = - &[CommandSpec::new("inspect.section", "Section Analysis", section).at("SOLID", "INSPECT").icon("section").noundo().params( + &[CommandSpec::new("inspect.section", "Section Analysis", crate::checked!("inspect.section", section)) + .contract(&SECTION).output(output_schema_for::) + .at("SOLID", "INSPECT").icon("section").noundo().params( "plane: XY|XZ|YZ | construction plane name or id | {origin, normal}; offset?: expr along the plane's normal (XY: +Z, XZ: +Y, YZ: +X); \ at?: expr, the cut's world coordinate on the named plane's axis (XY: Z, XZ: Y, YZ: X), instead of offset; \ flip?: bool; hatch?: bool (hatch the cut faces, in each body's colour; default true); or clear: true. Returns the cut's origin and normal, and for a named plane its axis and at", @@ -21,7 +25,7 @@ fn section(s: &mut Session, p: &Value) -> Result { s.section = None; s.status_slot(crate::status_items::StatusId::Section, false); s.revision += 1; - return Ok(json!({"section": null})); + return serde_json::to_value(contract::SectionResult { section: None, param_warnings: Vec::new() }).map_err(|e| bad(cmd, e.to_string())); } // A named plane's world axis: which component of a point lies along its normal. let (origin, normal, axis) = match p.get("plane") { @@ -79,12 +83,14 @@ fn section(s: &mut Session, p: &Value) -> Result { s.status_slot(crate::status_items::StatusId::Section, true); s.status_activated(crate::status_items::StatusId::Section); s.revision += 1; - let mut section = json!({"origin": origin, "normal": normal, "hatch": s.section_hatch}); - if let Some(i) = axis { - section["axis"] = json!(["X", "Y", "Z"].get(i).copied().unwrap_or("?")); - section["at"] = json!(axis_component(origin, i)); - } - Ok(json!({"section": section})) + let section = contract::Section { + origin: [origin.x, origin.y, origin.z], + normal: [normal.x, normal.y, normal.z], + hatch: s.section_hatch, + axis: axis.and_then(|i| ["X", "Y", "Z"].get(i).map(|v| (*v).to_owned())), + at: axis.map(|i| axis_component(origin, i)), + }; + serde_json::to_value(contract::SectionResult { section: Some(section), param_warnings: Vec::new() }).map_err(|e| bad(cmd, e.to_string())) } fn axis_component(v: Vec3, i: usize) -> f64 { diff --git a/crates/engine/src/cmd/sketch.rs b/crates/engine/src/cmd/sketch.rs index 5668c71440..9d2c48f228 100644 --- a/crates/engine/src/cmd/sketch.rs +++ b/crates/engine/src/cmd/sketch.rs @@ -37,7 +37,7 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("sketch.create", "Create Sketch", create_sketch) .at("SOLID", "CREATE") .icon("sketch") - .params("plane: XY|XZ|YZ | construction plane name or id | {origin?: [x,y,z] numbers in mm, default [0,0,0] (not an expression), x_dir, y_dir} (in world coordinates unless frame says otherwise; a named plane is the active component's own) | {face: [world x,y,z], body?: exact world body, index?: face index, path?: complete occurrence ids, fixed?: bool}; new face attachments require a unique certified source (a flat face of a sheet metal body attaches to its panel instead, in the flat pattern's coordinates), fixed:true explicitly freezes the selected plane; offset?: expr, flip?: bool | \"x\" | \"y\" (not on a face: sketch the plane from its other side, so the normal and the default extrude direction are reversed; a half turn about the plane's own x or y axis, true picks the one keeping the sketch's y axis upward; offset still moves along the unflipped normal), frame?: world (default) | component (which coordinates an explicit plane is in; the reply's `frame` says which, with a `note` when the active component is moved or turned), project_edges?: bool, name?"), + .params("plane: XY|XZ|YZ | construction plane name or id | {origin?: [x,y,z] numbers in mm, default [0,0,0] (not an expression), x_dir, y_dir} (in world coordinates unless frame says otherwise; a named plane is the active component's own) | {face: [world x,y,z], body?: exact world body, index?: face index, path?: complete occurrence ids, fixed?: bool}; new face attachments require a unique certified source (a flat face of a sheet metal body attaches to its panel instead, in the flat pattern's coordinates), fixed:true explicitly freezes the selected plane; offset?: expr, flip?: bool | \"x\" | \"y\" (not on a face: sketch the plane from its other side, so the normal and the default extrude direction are reversed; a half turn about the plane's own x or y axis, true picks the one keeping the sketch's y axis upward; offset still moves along the unflipped normal), frame?: world (default) | component (which coordinates an explicit plane is in; the reply's `frame` says which, with a `note` when the active component is moved or turned), project_edges?: bool, activate_part?: bool (default false; on a face of a body of another component than the active one: activate that component at the picked placement first, as component.activate would, and make the sketch there; it stays active afterwards and the reply's `activated` names it), name?"), CommandSpec::new("sketch.edit", "Edit Sketch", edit_sketch).noundo().icon("sketch").params("sketch: id or name"), CommandSpec::new("sketch.finish", "Finish Sketch", finish_sketch).at("SKETCH", "FINISH SKETCH").icon("finish").enabled(in_sketch).noundo(), CommandSpec::new("sketch.cancel", "Cancel Sketch", cancel_sketch) @@ -59,7 +59,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .icon("rect") .key("R") .enabled(in_sketch) - .params(concat!("p0, p1: opposite corners [x,y]; construction?. Returns sides {bottom, right, top, left} (line ids) and corners {bottom_left, bottom_right, top_right, top_left} (point ids)", connect_doc!(), created_doc!())), + .params(concat!("p0, p1: opposite corners [x,y] (a coordinate may be an expression over the design's parameters: the corner follows them); construction?. Returns sides {bottom, right, top, left} (line ids) and corners {bottom_left, bottom_right, top_right, top_left} (point ids)", connect_doc!(), created_doc!())), CommandSpec::new("sketch.rectangle.three_point", "3-Point Rectangle", rect_three_point) .at("SKETCH", "CREATE") .icon("rect3") @@ -74,14 +74,14 @@ pub static COMMANDS: &[CommandSpec] = &[ .at("SKETCH", "CREATE") .icon("rect_center") .enabled(in_sketch) - .params(concat!("center, corner; construction?: bool. Returns sides {bottom, right, top, left} (line ids) and corners {bottom_left, bottom_right, top_right, top_left} (point ids)", connect_doc!(), created_doc!())), + .params(concat!("center, corner: [x,y] (a coordinate may be an expression over the design's parameters: the point follows them); construction?: bool. Returns sides {bottom, right, top, left} (line ids) and corners {bottom_left, bottom_right, top_right, top_left} (point ids)", connect_doc!(), created_doc!())), CommandSpec::new("sketch.circle.center", "Center Diameter Circle", circle_center) .at("SKETCH", "CREATE") .icon("circle") .key("C") .enabled(in_sketch) .params(concat!( - "center: [x,y] or point ref, radius | diameter: number; through?: point ref (a point the circle passes through, kept on it: where the tool's second click snapped); construction?: bool", + "center: [x,y] or point ref (a coordinate may be an expression over the design's parameters: the centre follows them), radius | diameter: number or expression (an expression naming a parameter is a dimension driven by it; with `through` a point ref, that and the point both fix the radius); through?: point ref (a point the circle passes through, kept on it: where the tool's second click snapped); construction?: bool", connect_doc!(), created_doc!() )), @@ -94,7 +94,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .at("SKETCH", "CREATE") .icon("circle3") .enabled(in_sketch) - .params(concat!("p0, p1, p2; construction?: bool", connect_doc!(), created_doc!())), + .params(concat!("p0, p1, p2: [x,y] or point ref (a referenced point stays on the circle); construction?: bool", connect_doc!(), created_doc!())), CommandSpec::new("sketch.arc.three_point", "3-Point Arc", arc_three).at("SKETCH", "CREATE").icon("arc3").enabled(in_sketch).params(concat!("start, end, through: [x,y] or point ref (a referenced through point stays on the arc); construction?: bool", connect_doc!(), created_doc!())), CommandSpec::new("sketch.arc.center_point", "Center Point Arc", arc_center) .at("SKETCH", "CREATE") @@ -288,6 +288,9 @@ pub(super) fn edit_solved( } // The stored sketch is solved for its stored values. let stored = sk.driving_values(); + // The constraints the sketch has before the edit: a conflict message must not name one the + // edit itself adds (the trial already holds it under the id it would get, #9735). + let pre: Vec = sk.constraints.iter().map(|c| c.id.clone()).collect(); let (out, left) = f(&mut sk, &mut doc)?; // A curve that took an offset spline's generated node (drawn from its end) gets its own point there. sk.detach_offset_points()?; @@ -307,7 +310,7 @@ pub(super) fn edit_solved( sk.refresh_offsets()?; rep = counted_solve(&mut sk, &Hold::default(), Some(&reference), analysis, None); } - Some(settle(strict, rep, &mut sk, unsolved)?) + Some(settle(strict, rep, &mut sk, unsolved, &pre)?) } // Solved for the document's values, continuing from the stored solved state. Left::Solved | Left::Unsolved => { @@ -317,7 +320,7 @@ pub(super) fn edit_solved( #[cfg(test)] solve_counts::record(analysis); let rep = doc.solve_sketch_with_analysis(&vals, &mut sk, &stored, None, analysis)?; - Some(settle(strict, rep, &mut sk, unsolved)?) + Some(settle(strict, rep, &mut sk, unsolved, &pre)?) } }; sketch_store::checked(&sk)?; @@ -329,8 +332,20 @@ pub(super) fn edit_solved( /// An edit's solve outcome: with `strict`, a solve that fails or turns the sketch over is an /// error; otherwise such a sketch is stored as the edit left it before the solve, its values /// and geometry together, so the stored sketch stays solved for its stored values. -fn settle(strict: bool, rep: SolveReport, sk: &mut Sketch, unsolved: Sketch) -> Result { +/// `pre` are the constraint ids the sketch had before the edit: the trial already holds any +/// constraint the edit adds, under the id it would get, so a conflict list that named it would +/// name an id the sketch does not have yet (#9735). +fn settle(strict: bool, mut rep: SolveReport, sk: &mut Sketch, unsolved: Sketch, pre: &[String]) -> Result { if strict && !rep.keeps_orientation() { + if rep.folded.is_empty() && rep.oversized.is_none() && !rep.failing.is_empty() { + let had: Vec = rep.failing.iter().filter(|id| pre.iter().any(|p| p == *id)).cloned().collect(); + if had.is_empty() { + return Err(EngineError::Other( + "that would over-constrain the sketch (it conflicts with what the other constraints already determine)".into(), + )); + } + rep.failing = had; + } return Err(EngineError::Other(rep.failure(|f| format!("that would over-constrain the sketch (conflicts with {f})")))); } // A solve that throws points far beyond the sketch found a far solution of its constraints, @@ -373,6 +388,21 @@ fn shrunk(before: &Sketch, after: &Sketch) -> Option { /// dimension value) has thrown the geometry to a far solution. const FLUNG: f64 = 1000.0; +/// Why a solve from `before` into `after` collapses the sketch, in the words `settle` uses to +/// refuse an edit's solve: a line or circle shrunk to a point, or points thrown far away. +/// `None` when the solved shape is one the edit can mean. Value edits (`Session::settle_value_edit`) +/// run the same checks, so a parameter, a configuration row or the dimension editor cannot +/// store a sketch whose curve has been squeezed away and which can never be solved back. +pub(crate) fn collapse_reason(before: &Sketch, after: &Sketch) -> Option { + if let Some(curve) = shrunk(before, after) { + return Some(format!("that would shrink {curve} to a point: its constraints can only be met without it")); + } + if flung(before, after) { + return Some("that would throw points of the sketch far away: its constraints have no solution near the sketch as it is".into()); + } + None +} + /// Did solving `before` into `after` throw some point further than [`FLUNG`] times the sketch's /// size, or to no number at all? fn flung(before: &Sketch, after: &Sketch) -> bool { @@ -548,10 +578,7 @@ pub(super) fn expr_point(doc: &solidcraft_doc::Document, v: &Value, cmd: &str) - /// again whenever the parameters change. pub(super) fn add_placed_point(sk: &mut Sketch, at: Vec2, exprs: PointExprs, id: Option<&str>) -> Result { let i = sk.add_point(at, id)?; - if let Some(q) = sk.points.get_mut(i) { - q.fixed = true; - q.exprs = Some(Box::new(exprs)); - } + place_point(sk, Some(&i), exprs); Ok(i) } @@ -791,8 +818,43 @@ fn create_sketch(s: &mut Session, p: &Value) -> Result { return Err(bad(cmd, format!("`{key}` belongs inside `plane`: {example}"))); } } + let activate = match p.get("activate_part") { + None | Some(Value::Null) => false, + Some(Value::Bool(on)) => *on, + Some(_) => return Err(bad(cmd, "`activate_part` must be true or false")), + }; let frame = plane_frame(p, cmd)?; - let mut plane = plane_ref(s, p, cmd)?; + let plane = plane_ref(s, p, cmd)?; + // On a face of another component's body, `activate_part` activates that component at the + // picked placement first, as `component.activate` would, and the sketch is made there. It + // stays active, as after an explicit activate, so what is built from the sketch lands in the + // same part. On an error the active component goes back to what it was. + let owner = match p.get("plane") { + Some(value) if activate && face::is_face(p) => face::pick_owner(s, value, &plane, cmd)?, + _ => None, + }; + let Some((component, path)) = owner.filter(|(component, _)| *component != s.active_component) else { + return create_sketch_on(s, p, plane, frame, cmd); + }; + let before = (s.active_component, s.active_occurrence_path.clone()); + s.active_component = component; + s.active_occurrence_path = (!path.is_empty()).then(|| path.clone()); + s.revision += 1; + let made = plane_ref(s, p, cmd).and_then(|plane| create_sketch_on(s, p, plane, frame, cmd)); + match made { + Ok(mut out) => { + out["activated"] = json!({"component": component, "path": path}); + Ok(out) + } + Err(error) => { + (s.active_component, s.active_occurrence_path) = before; + Err(error) + } + } +} + +/// The rest of `sketch.create` once the active component is settled: `flip`, the sketch, the reply's `frame`. +fn create_sketch_on(s: &mut Session, p: &Value, mut plane: PlaneRef, frame: Option<&str>, cmd: &str) -> Result { if let Some(about_y) = flip_param(s, p, &plane, cmd)? { plane = PlaneRef::Flipped { base: Box::new(plane), about_y }; } @@ -1084,18 +1146,20 @@ fn rect_constraints(sk: &mut Sketch, l: &[usize]) -> Result> { Ok(cons) } -/// Whether line `l` being horizontal (`horizontal`) or vertical already follows from another -/// constraint than `own`: its ends coincide with the ends of a line constrained the same way (a -/// side two rectangles share), or with two points constrained level (or plumb). -fn axis_implied(sk: &Sketch, l: usize, horizontal: bool, own: &str) -> bool { - let Some(CurveKind::Line { a, b }) = sk.curves.get(l).map(|c| &c.kind) else { return false }; - let (a, b) = (*a, *b); - // Coincident points as one (union-find over the coincident constraints). +/// Canonical point indices for shared and transitively coincident sketch endpoints. +/// Kept inside the engine for command connectivity and implied-axis checks. +pub(super) fn coincident_points(sk: &Sketch) -> Vec { let mut parent: Vec = (0..sk.points.len()).collect(); - let root = |parent: &[usize], mut i: usize| { + let root = |parent: &mut [usize], mut i: usize| { for _ in 0..parent.len() { match parent.get(i) { - Some(&up) if up != i => i = up, + Some(&up) if up != i => { + let grandparent = parent.get(up).copied().unwrap_or(up); + if let Some(slot) = parent.get_mut(i) { + *slot = grandparent; + } + i = up; + } _ => break, } } @@ -1103,7 +1167,7 @@ fn axis_implied(sk: &Sketch, l: usize, horizontal: bool, own: &str) -> bool { }; for c in &sk.constraints { if let ConstraintKind::Coincident { p, q } = c.kind { - let (rp, rq) = (root(&parent, p), root(&parent, q)); + let (rp, rq) = (root(&mut parent, p), root(&mut parent, q)); if rp != rq && let Some(slot) = parent.get_mut(rp) { @@ -1111,7 +1175,18 @@ fn axis_implied(sk: &Sketch, l: usize, horizontal: bool, own: &str) -> bool { } } } - let (ra, rb) = (root(&parent, a), root(&parent, b)); + (0..parent.len()).map(|i| root(&mut parent, i)).collect() +} + +/// Whether line `l` being horizontal (`horizontal`) or vertical already follows from another +/// constraint than `own`: its ends coincide with the ends of a line constrained the same way (a +/// side two rectangles share), or with two points constrained level (or plumb). +fn axis_implied(sk: &Sketch, l: usize, horizontal: bool, own: &str) -> bool { + let Some(CurveKind::Line { a, b }) = sk.curves.get(l).map(|c| &c.kind) else { return false }; + let (a, b) = (*a, *b); + let groups = coincident_points(sk); + let root = |i: usize| groups.get(i).copied().unwrap_or(i); + let (ra, rb) = (root(a), root(b)); sk.constraints.iter().filter(|c| c.id != own).any(|c| { let ends = match (&c.kind, horizontal) { (ConstraintKind::Horizontal { l: m }, true) | (ConstraintKind::Vertical { l: m }, false) if *m != l => { @@ -1124,7 +1199,7 @@ fn axis_implied(sk: &Sketch, l: usize, horizontal: bool, own: &str) -> bool { _ => None, }; ends.is_some_and(|(p, q)| { - let (rp, rq) = (root(&parent, p), root(&parent, q)); + let (rp, rq) = (root(p), root(q)); rp != rq && ((rp, rq) == (ra, rb) || (rp, rq) == (rb, ra)) }) }) @@ -1173,16 +1248,42 @@ fn rect_result(sk_out: (Vec, Vec), names: Value, info: Value) -> out } +/// A rectangle corner: `[x, y]` of numbers, or with a coordinate typed as an expression (as a +/// `sketch.line` point is): where it is now and the expressions that place it. +fn corner_arg(doc: &solidcraft_doc::Document, p: &Value, k: &str, cmd: &str) -> Result<(Vec2, PointExprs)> { + let v = p.get(k).ok_or_else(|| bad(cmd, format!("`{k}` must be a point [x, y]")))?; + match expr_point(doc, v, cmd)? { + Some((at, exprs)) => Ok((at, exprs.unwrap_or_default())), + None => Ok((req_vec2(cmd, p, k)?, PointExprs::default())), + } +} + +/// Holds sketch point `i` where `exprs` place it, when it has any (see [`add_placed_point`]). +fn place_point(sk: &mut Sketch, i: Option<&usize>, exprs: PointExprs) { + if exprs.x.is_none() && exprs.y.is_none() { + return; + } + if let Some(q) = i.and_then(|i| sk.points.get_mut(*i)) { + q.fixed = true; + q.exprs = Some(Box::new(exprs)); + } +} + fn rect_two_point(s: &mut Session, p: &Value) -> Result { let cmd = "sketch.rectangle.two_point"; - let (a, b) = (req_vec2(cmd, p, "p0")?, req_vec2(cmd, p, "p1")?); + let ((a, ea), (b, eb)) = (corner_arg(&s.doc, p, "p0", cmd)?, corner_arg(&s.doc, p, "p1", cmd)?); if (a.x - b.x).abs() < 1e-9 || (a.y - b.y).abs() < 1e-9 { return Err(bad(cmd, "the corners must differ in x and y")); } let (lo, hi) = (Vec2::new(a.x.min(b.x), a.y.min(b.y)), Vec2::new(a.x.max(b.x), a.y.max(b.y))); + // Each side of the rectangle takes its coordinate from the corner it comes from. + let (lo_x, hi_x) = if a.x <= b.x { (&ea.x, &eb.x) } else { (&eb.x, &ea.x) }; + let (lo_y, hi_y) = if a.y <= b.y { (&ea.y, &eb.y) } else { (&eb.y, &ea.y) }; let ((out, names), info) = edit(s, p, cmd, false, |sk, _| { let first = sk.points.len(); let (l, pts) = polygon_lines(sk, &[lo, Vec2::new(hi.x, lo.y), hi, Vec2::new(lo.x, hi.y)])?; + place_point(sk, pts.first(), PointExprs { x: lo_x.clone(), y: lo_y.clone() }); + place_point(sk, pts.get(2), PointExprs { x: hi_x.clone(), y: hi_y.clone() }); let mut cons = rect_constraints(sk, &l)?; mark_construction(sk, &l, p); cons.extend(connect_points(sk, first, p)?); @@ -1258,7 +1359,7 @@ fn rect_rounded(s: &mut Session, p: &Value) -> Result { fn rect_center(s: &mut Session, p: &Value) -> Result { let cmd = "sketch.rectangle.center"; - let (c, k) = (req_vec2(cmd, p, "center")?, req_vec2(cmd, p, "corner")?); + let ((c, ec), (k, ek)) = (corner_arg(&s.doc, p, "center", cmd)?, corner_arg(&s.doc, p, "corner", cmd)?); let h = Vec2::new((k.x - c.x).abs(), (k.y - c.y).abs()); if h.x < 1e-9 || h.y < 1e-9 { return Err(bad(cmd, "the corner must differ from the centre in x and y")); @@ -1266,6 +1367,14 @@ fn rect_center(s: &mut Session, p: &Value) -> Result { let ((out, names), info) = edit(s, p, cmd, false, |sk, _| { let first = sk.points.len(); let (l, pts) = polygon_lines(sk, &[c - h, Vec2::new(c.x + h.x, c.y - h.y), c + h, Vec2::new(c.x - h.x, c.y + h.y)])?; + // The corner given sits in the quadrant it was drawn in. + let at = match (k.x >= c.x, k.y >= c.y) { + (true, true) => 2, + (false, true) => 3, + (true, false) => 1, + (false, false) => 0, + }; + place_point(sk, pts.get(at), ek); let mut cons = rect_constraints(sk, &l)?; // Both diagonals (construction), the centre at their crossing. Only the first carries // the midpoint constraint: diagonals of a parallelogram bisect each other, so a second @@ -1279,6 +1388,7 @@ fn rect_center(s: &mut Session, p: &Value) -> Result { } } let cp = sk.add_point(c, None)?; + place_point(sk, Some(&cp), ec); cons.push(add_c(sk, ConstraintKind::Midpoint { p: cp, l: diag })?); } mark_construction(sk, &l, p); @@ -1315,16 +1425,43 @@ fn rect_three_point(s: &mut Session, p: &Value) -> Result { pub(super) fn radius_arg(p: &Value, cmd: &str) -> Result { let r = match (num(p, "radius"), num(p, "diameter")) { (Some(r), _) => r, + // `radius` wins over `diameter` whatever its form, so a bad one is not passed over. + (None, _) if p.get("radius").is_some_and(|v| !v.is_null()) => return Err(bad(cmd, "`radius` must be a number")), (None, Some(d)) => d / 2.0, - _ => return Err(bad(cmd, "needs `radius` or `diameter`")), + // A value that is there but is no number is not a missing one. + _ => { + let given = ["radius", "diameter"].into_iter().find(|k| p.get(k).is_some_and(|v| !v.is_null())); + return Err(bad(cmd, given.map_or_else(|| "needs `radius` or `diameter`".to_string(), |k| format!("`{k}` must be a number")))); + } }; if r > 1e-9 { Ok(r) } else { Err(bad(cmd, "radius must be positive")) } } +/// The size of a circle: [`radius_arg`], or a `radius` / `diameter` typed as an expression over +/// the design's parameters, which is then a driving dimension on the circle (the parameter +/// `dN` holds the expression) so that the circle follows it. A bare number reads in the design's +/// unit, and an expression naming no parameter is just the number it comes to. Returns the +/// radius, and the expression with whether it is a diameter. `radius` wins over `diameter`. +fn circle_size(s: &Session, p: &Value, cmd: &str) -> Result<(f64, Option<(String, bool)>)> { + let radius_given = p.get("radius").is_some_and(|v| !v.is_null()); + let key = if radius_given { "radius" } else { "diameter" }; + if !p.get(key).is_some_and(Value::is_string) { + return Ok((radius_arg(p, cmd)?, None)); + } + let e = s.doc.with_design_unit(req_expr(cmd, p, key)?.as_str(), Kind::Length); + let v = s.doc.eval(&e, Kind::Length).map_err(|e| bad(cmd, format!("{key}: {e}")))?; + let r = if key == "diameter" { v / 2.0 } else { v }; + if !(r > 1e-9 && r < 1e8) { + return Err(bad(cmd, "radius must be positive")); + } + let driven = !solidcraft_doc::expr::references(&e).is_empty(); + Ok((r, driven.then_some((e, key == "diameter")))) +} + fn circle_center(s: &mut Session, p: &Value) -> Result { let cmd = "sketch.circle.center"; let p = &super::sketch_project::snap_points(s, p, cmd)?; - let r = radius_arg(p, cmd)?; + let (r, size) = circle_size(s, p, cmd)?; let id = named_id(cmd, str_(p, "id"))?; let (out, info) = draw(s, p, cmd, |sk, doc| { let c = req_parg(sk, doc, p, "center", cmd)?; @@ -1343,6 +1480,13 @@ fn circle_center(s: &mut Session, p: &Value) -> Result { { cons.push(add_c(sk, ConstraintKind::PointOnCurve { p: q, c: ci })?); } + // A size typed as an expression is a dimension driven by the parameters it names. + if let Some((e, diameter)) = &size { + let unit = solidcraft_doc::expr::length_unit_or_mm(&doc.units).to_string(); + let pname = doc.new_model_param(e, &unit); + let kind = if *diameter { ConstraintKind::Diameter { c: ci, value: 2.0 * r } } else { ConstraintKind::Radius { c: ci, value: r } }; + cons.push(sk.add_constraint(kind, Some(pname))?); + } Ok((ids_of(sk, &[ci]), cons)) })?; Ok(result(out, info)) @@ -1379,12 +1523,21 @@ pub(super) fn circumcircle(a: Vec2, b: Vec2, c: Vec2) -> Option<(Vec2, f64)> { fn circle_three(s: &mut Session, p: &Value) -> Result { let cmd = "sketch.circle.three_point"; - let (a, b, c) = (req_vec2(cmd, p, "p0")?, req_vec2(cmd, p, "p1")?, req_vec2(cmd, p, "p2")?); - let (o, r) = circumcircle(a, b, c).ok_or_else(|| bad(cmd, "the points are collinear"))?; - let (out, info) = draw(s, p, cmd, |sk, _| { + let p = &super::sketch_project::snap_points(s, p, cmd)?; + let (out, info) = draw(s, p, cmd, |sk, doc| { + let (a, b, c) = (req_parg(sk, doc, p, "p0", cmd)?, req_parg(sk, doc, p, "p1", cmd)?, req_parg(sk, doc, p, "p2", cmd)?); + let (o, r) = circumcircle(a.pos(), b.pos(), c.pos()).ok_or_else(|| bad(cmd, "the points are collinear"))?; let ci = sk.add_circle(o, r, None, None)?; mark_construction(sk, &[ci], p); - Ok((ids_of(sk, &[ci]), Vec::new())) + // A click snapped to a point keeps the circle on it (#9858), as the centre circle's + // `through` click does (#3099) and the 3-Point Arc's clicks do. + let mut cons = Vec::new(); + for q in [a, b, c] { + if let Some(point) = q.idx() { + cons.push(add_c(sk, ConstraintKind::PointOnCurve { p: point, c: ci })?); + } + } + Ok((ids_of(sk, &[ci]), cons)) })?; Ok(result(out, info)) } @@ -1677,7 +1830,7 @@ fn constrain(s: &mut Session, p: &Value, cmd: &str, f: impl FnOnce(&mut Sketch) // fully determined: it is then redundant, see `redundancy`). let holds = residual.is_some_and(|r| r <= solidcraft_sketch::CONSISTENT); if let Some((first, second)) = order.filter(|_| !holds) { - apply_in_order(sk, &rep, &[first], second, cmd)?; + apply_in_order(sk, &rep, &[first], second, cmd, std::slice::from_ref(&id))?; } Ok(id) })?; @@ -1766,7 +1919,9 @@ fn keep_size(sk: &Sketch, e: Ent) -> Vec { /// If the second can't move (fixed, projected or fully determined), the first moves instead; /// if neither can, the constraint conflicts and nothing changes. Everything else moves as /// little as it can. Later solves are ordinary ones (nothing is held). -pub(super) fn apply_in_order(sk: &mut Sketch, rep: &SolveReport, first: &[Ent], second: Ent, cmd: &str) -> Result<()> { +/// `new` are the ids of the constraints the command just added: the refusal names the +/// constraints the new one conflicts with, never the new one's own id (#9735). +pub(super) fn apply_in_order(sk: &mut Sketch, rep: &SolveReport, first: &[Ent], second: Ent, cmd: &str, new: &[String]) -> Result<()> { let second_locked = ent_locked(sk, rep, second); // (held geometry, temporary size constraints) to try in turn. let mut tries: Vec<(Hold, Vec)> = Vec::new(); @@ -1826,9 +1981,90 @@ pub(super) fn apply_in_order(sk: &mut Sketch, rep: &SolveReport, first: &[Ent], return Ok(()); } } + // Neither holding one side nor a plain solve places it. Name the constraints the new one + // conflicts with (the sketch may well have free degrees of freedom: the conflict is with + // its own constraints, not the entities being fixed or fully constrained, #9735). The + // violated solve names only what it gave up on; the conflict is the sketch's own set of + // constraints that leaves the new one unsatisfiable. + let conflicts: Vec = sk + .constraints + .iter() + .position(|c| new.contains(&c.id)) + .map(|ci| solidcraft_sketch::determined_by(sk, ci)) + .unwrap_or_default() + .into_iter() + .filter_map(|k| sk.constraints.get(k)) + .filter(|c| !new.contains(&c.id)) + .map(|c| c.id.clone()) + .collect(); + let conflicts = if conflicts.is_empty() { conflict_probe(sk, new) } else { conflicts }; + let conflicts = if conflicts.is_empty() { direction_conflicts(sk, new) } else { conflicts }; + if !conflicts.is_empty() { + return Err(bad(cmd, format!("that conflicts with the sketch: it conflicts with {}", conflicts.join(", ")))); + } Err(bad(cmd, "that conflicts with the sketch: neither entity can move to satisfy it (they are fixed or fully constrained)")) } +/// A sketch this large is not probed constraint by constraint for a conflict message. +const CONFLICT_PROBE_MAX: usize = 64; + +/// The sketch's own constraints (ids, in creation order) that keep the just-added constraints +/// `new` unsatisfiable: each constraint whose removal lets the sketch solve again is dropped, +/// so what is left is a set the new constraint genuinely conflicts with (a constraint the +/// failure does not need is not named). A solve that collapses or flings the sketch is not a +/// solution. Empty when the sketch is too large to probe, already solves, or fails whatever +/// the sketch's own constraints — the new one asks the impossible of the geometry itself. +fn conflict_probe(sk: &Sketch, new: &[String]) -> Vec { + let is_new = |c: &solidcraft_sketch::Constraint| new.contains(&c.id); + let holds = |s: &Sketch| { + let mut t = s.clone(); + let rep = solve_with(&mut t, &Hold::default(), None, Analysis::StatusOnly, None); + rep.ok() && shrunk(s, &t).is_none() && !flung(s, &t) + }; + if sk.constraints.len() > CONFLICT_PROBE_MAX || holds(sk) { + return Vec::new(); + } + let mut keep: std::collections::HashSet = sk.constraints.iter().filter(|c| !is_new(c)).map(|c| c.id.clone()).collect(); + for id in sk.constraints.iter().filter(|c| !is_new(c)).map(|c| c.id.clone()).collect::>() { + if !keep.contains(&id) { + continue; + } + let mut t = sk.clone(); + t.constraints.retain(|c| is_new(c) || (c.id != id && keep.contains(&c.id))); + if !holds(&t) { + keep.remove(&id); + } + } + sk.constraints.iter().filter(|c| keep.contains(&c.id)).map(|c| c.id.clone()).collect() +} + +/// The existing direction constraints (horizontal, vertical, angle, parallel …) that read one +/// of the two lines of a just-added Parallel or Perpendicular: the solver cannot turn a line +/// off a direction another constraint already fixed (and reads no gradient to turn with at a +/// line pair already at the forbidden angle), so those constraints are what it conflicts with. +/// Empty for any other kind of new constraint. +fn direction_conflicts(sk: &Sketch, new: &[String]) -> Vec { + let pair = sk.constraints.iter().find(|c| new.contains(&c.id)).and_then(|c| match c.kind { + ConstraintKind::Parallel { a, b } | ConstraintKind::Perpendicular { a, b } => Some((a, b)), + _ => None, + }); + let Some((a, b)) = pair else { return Vec::new() }; + sk.constraints + .iter() + .filter(|c| !new.contains(&c.id)) + .filter(|c| { + matches!(c.kind, + ConstraintKind::Horizontal { l } | ConstraintKind::Vertical { l } | ConstraintKind::LineAngle { l, .. } + if l == a || l == b + ) || matches!(c.kind, + ConstraintKind::Parallel { a: x, b: y } | ConstraintKind::Perpendicular { a: x, b: y } | ConstraintKind::Collinear { a: x, b: y } | ConstraintKind::Angle { a: x, b: y, .. } + if x == a || x == b || y == a || y == b + ) + }) + .map(|c| c.id.clone()) + .collect() +} + /// Degrees of freedom of a sketch as it is. pub(super) fn dof_now(sk: &Sketch) -> usize { solve_with(&mut sk.clone(), &Hold::default(), None, Analysis::Freedom, None).dof @@ -2072,7 +2308,7 @@ fn coincident_circles(s: &mut Session, p: &Value, cmd: &str, a: usize, b: usize) if ids.is_empty() { return Err(bad(cmd, "these circles already lie on each other")); } - apply_in_order(sk, &rep, &[Ent::Curve(a)], Ent::Curve(b), cmd)?; + apply_in_order(sk, &rep, &[Ent::Curve(a)], Ent::Curve(b), cmd, &ids)?; Ok(ids) })?; Ok(json!({"constraint": ids[0], "constraints": ids, "sketch": info})) @@ -2362,7 +2598,7 @@ fn c_symmetry(s: &mut Session, p: &Value) -> Result { } // The first entity and the symmetry line stay; the second mirrors onto it. if let Some((a, b)) = order { - apply_in_order(sk, &rep, &[a, Ent::Curve(l)], b, cmd)?; + apply_in_order(sk, &rep, &[a, Ent::Curve(l)], b, cmd, &out)?; } Ok(out) })?; @@ -2414,7 +2650,7 @@ fn symmetry_about_point(sk: &mut Sketch, p: &Value, c: usize, cmd: &str) -> Resu let mut out = symmetry_pairs(sk, &[(pa, pb)], c, cmd)?; out.push(add_c(sk, ConstraintKind::Equal { a: ca, b: cb })?); if let Some((a, b)) = order { - apply_in_order(sk, &rep, &[a, Ent::Point(c)], b, cmd)?; + apply_in_order(sk, &rep, &[a, Ent::Point(c)], b, cmd, &out)?; } return Ok(out); } @@ -2425,7 +2661,7 @@ fn symmetry_about_point(sk: &mut Sketch, p: &Value, c: usize, cmd: &str) -> Resu let out = symmetry_pairs(sk, &pairs, c, cmd)?; // The first entity and the centre stay; the second mirrors onto it. if let Some((a, b)) = order { - apply_in_order(sk, &rep, &[a, Ent::Point(c)], b, cmd)?; + apply_in_order(sk, &rep, &[a, Ent::Point(c)], b, cmd, &out)?; } Ok(out) } diff --git a/crates/engine/src/cmd/sketch_3d_frame_tests.rs b/crates/engine/src/cmd/sketch_3d_frame_tests.rs index a46cbad779..21b385c35c 100644 --- a/crates/engine/src/cmd/sketch_3d_frame_tests.rs +++ b/crates/engine/src/cmd/sketch_3d_frame_tests.rs @@ -439,3 +439,36 @@ fn line3d_spline3d_point3d_need_a_3d_sketch() { assert!(info.enabled, "{id} not offered inside a 3D sketch: {:?}", info.disabled_reason); } } + +#[test] +fn wire_frame_copies_keep_component_and_legacy_coordinates() { + let (mut s, _, first, second) = second_frame(); + let sketch = s.active_sketch.unwrap(); + let fit = [Vec3::new(20.0, 40.0, 45.0), Vec3::new(10.0, 30.0, 45.0), Vec3::new(20.0, 20.0, 45.0)]; + let local = run(&mut s, "sketch.arc3d", json!({"start":fit[0],"through":fit[1],"end":fit[2]}))["curves"][0].as_str().unwrap().to_owned(); + let legacy = s.doc_mut().sketch_mut(sketch).unwrap().add_drawn_arc_in(&fit, WireSpace::World).unwrap(); + s.refresh(); + let originals = s.doc.sketch(sketch).unwrap().wires.clone(); + let out = run(&mut s, "sketch.move", json!({"entities":[local,legacy],"copy":true,"translate":[1.6,0],"angle":90})); + let copied: Vec<_> = out["curves"].as_array().unwrap().iter().map(|id| id.as_str().unwrap().to_owned()).collect(); + assert_eq!(copied.len(), 2); + let sk = s.doc.sketch(sketch).unwrap(); + assert_eq!(&sk.wires[..2], &originals); + assert_eq!(sk.wires[2].space, WireSpace::Component); + assert_eq!(sk.wires[3].space, WireSpace::World); + near(sk.wires[2].arc.unwrap().center, Vec3::new(1.6, 0.0, 5.0)); + near(sk.wires[3].arc.unwrap().center, Vec3::new(20.0, 31.6, 45.0)); + for wire in s.world_state().sketch(sketch).unwrap().sketch.wires.iter().filter(|w| copied.contains(&w.id)) { + near(wire.arc.unwrap().center, Vec3::new(20.0, 31.6, 45.0)); + near(wire.fit[0], Vec3::new(10.0, 31.6, 45.0)); + } + let component = s.active_component; + run(&mut s, "component.activate", json!({"component":component,"occurrence":first,"path":[first]})); + let world = s.world_state(); + let wires = &world.sketch(sketch).unwrap().sketch.wires; + near(wires.iter().find(|w| w.id == copied[0]).unwrap().arc.unwrap().center, Vec3::new(1.6, 0.0, 5.0)); + near(wires.iter().find(|w| w.id == copied[1]).unwrap().arc.unwrap().center, Vec3::new(20.0, 31.6, 45.0)); + run(&mut s, "component.activate", json!({"component":component,"occurrence":second,"path":[second]})); + let restored = solidcraft_doc::format::read(&s.doc.to_json()).unwrap(); + assert_eq!(restored.sketch(sketch).unwrap().wires, s.doc.sketch(sketch).unwrap().wires); +} diff --git a/crates/engine/src/cmd/sketch_clipboard.rs b/crates/engine/src/cmd/sketch_clipboard.rs index b9eddfa49d..fbe6b3c4bb 100644 --- a/crates/engine/src/cmd/sketch_clipboard.rs +++ b/crates/engine/src/cmd/sketch_clipboard.rs @@ -71,6 +71,38 @@ pub struct SketchClip { pub pastes: u32, } +impl SketchClip { + /// This clipboard as another design pastes it (#9258): at the same sketch coordinates (its + /// sketch ids mean nothing there), and with nothing that names the copied design's + /// parameters. A dimension whose expression names a parameter keeps its value, in its unit + /// (another design's parameter of the same name would be another thing); a text keeps its + /// words, height and angle as numbers. + pub fn for_another_design(&self) -> SketchClip { + let mut clip = self.clone(); + clip.sketch = u64::MAX; + clip.pastes = 0; + for c in &mut clip.constraints { + let Some((expr, unit)) = c.param.as_mut() else { continue }; + if solidcraft_doc::expr::references(expr).is_empty() { + continue; + } + let v = c.kind.value().unwrap_or_default(); + if solidcraft_doc::expr::unit_info(unit).is_none() { + *unit = if c.kind.is_angle() { "rad" } else { "mm" }.into(); + } + let value = if c.kind.is_angle() { solidcraft_doc::expr::Value::angle(v) } else { solidcraft_doc::expr::Value::length(v) }; + let shown = solidcraft_doc::expr::value_in_unit(value, unit); + *expr = format!("{}", if shown.is_finite() { shown } else { 0.0 }); + } + for (src, _) in &mut clip.texts { + if let LinkSource::Text { exprs, .. } = src { + *exprs = Default::default(); + } + } + clip + } +} + /// Curve and point ids named by `entities`, or the selected sketch geometry. fn chosen(s: &Session, p: &Value, cmd: &str) -> Result> { if p.get("entities").is_some() { diff --git a/crates/engine/src/cmd/sketch_clipboard_tests.rs b/crates/engine/src/cmd/sketch_clipboard_tests.rs index 7d1e919e96..72371c6c8c 100644 --- a/crates/engine/src/cmd/sketch_clipboard_tests.rs +++ b/crates/engine/src/cmd/sketch_clipboard_tests.rs @@ -251,3 +251,45 @@ fn clipboard_commands_refuse_parameters_that_are_not_an_object() { // Nothing was cut or pasted by the refused calls. assert!(s.execute("sketch.paste", &json!({})).is_ok(), "the clipboard still holds the copy"); } + +/// Sketch geometry copied in one design pastes into a sketch of another (#9258): at the same +/// coordinates, with its constraints and dimensions. A dimension that named the first design's +/// parameter keeps its value as a number in its unit (the other design's `w` is another thing); +/// one typed as a number keeps it. +#[test] +fn sketch_geometry_pastes_into_another_design_with_values_not_foreign_parameters() { + let mut a = Session::default(); + run(&mut a, "parameters.change", json!({"name": "w", "expression": "1 in"})); + run(&mut a, "sketch.create", json!({"plane": "XY"})); + let rect = strs(&run(&mut a, "sketch.rectangle.two_point", json!({"p0": [5, 5], "p1": [30, 15]}))["curves"]); + run(&mut a, "sketch.dimension", json!({"entities": [rect[0]], "value": "w"})); + run(&mut a, "sketch.dimension", json!({"entities": [rect[1]], "value": 10})); + let sa = a.active_sketch.unwrap(); + let corner = pos(sketch(&a, sa), &rect[0]); + run(&mut a, "sketch.copy", json!({"entities": rect})); + let clip = a.sketch_clipboard.clone().unwrap(); + + let mut b = Session::default(); + // Another design with its own `w`, and a sketch with the copied one's id. + run(&mut b, "parameters.change", json!({"name": "w", "expression": "3 mm"})); + run(&mut b, "sketch.create", json!({"plane": "XZ"})); + let sb = b.active_sketch.unwrap(); + assert_eq!(sb, sa, "same id: the copy must still not be offset as if pasted back into its own sketch"); + b.sketch_clipboard = Some(clip.for_another_design()); + let pasted = strs(&run(&mut b, "sketch.paste", json!({}))["curves"]); + assert_eq!(pasted.len(), 4); + let sk = sketch(&b, sb); + let at = pos(sk, &pasted[0]); + assert!(at.iter().zip(&corner).all(|(p, q)| p.dist(*q) < 1e-6), "{at:?} vs {corner:?}"); + let exprs: Vec = sk.constraints.iter().filter_map(|c| c.param.as_ref()).filter_map(|n| b.doc.param(n)).map(|p| p.expr.clone()).collect(); + assert_eq!(exprs.len(), 2, "{exprs:?}"); + assert!(exprs.iter().all(|e| solidcraft_doc::expr::references(e).is_empty()), "{exprs:?}"); + assert!(exprs.iter().any(|e| e == "10"), "{exprs:?}"); + let width = sk.constraints.iter().filter_map(|c| c.kind.value()).fold(0.0_f64, f64::max); + assert!((width - 25.4).abs() < 1e-6, "the width stays 1 in, not the other design's w: {width}"); + assert!(b.doc.params.iter().filter(|p| p.name == "w").all(|p| p.expr == "3 mm"), "its own w is untouched"); + // Each paste without a position lands at the copied coordinates. + let again = strs(&run(&mut b, "sketch.paste", json!({}))["curves"]); + let at = pos(sketch(&b, sb), &again[0]); + assert!(at.iter().zip(&corner).all(|(p, q)| p.dist(*q) < 1e-6), "{at:?}"); +} diff --git a/crates/engine/src/cmd/sketch_constraints_tests.rs b/crates/engine/src/cmd/sketch_constraints_tests.rs index 1c21d29e74..3c136dd178 100644 --- a/crates/engine/src/cmd/sketch_constraints_tests.rs +++ b/crates/engine/src/cmd/sketch_constraints_tests.rs @@ -455,6 +455,27 @@ fn drawing_at_a_curve_crossing_keeps_the_point_on_both() { assert!(st.y > 0.0, "the upper crossing: {st:?}"); } +#[test] +fn drawing_at_an_infinite_line_crossing_keeps_the_point_on_both() { + let mut s = new_sketch(); + let a = ids(&run(&mut s, "sketch.construction_line.vertical", json!({"at": [20, 40]}))["curves"])[0].clone(); + let b = ids(&run(&mut s, "sketch.construction_line.horizontal", json!({"at": [40, 15]}))["curves"])[0].clone(); + let r = run(&mut s, "sketch.line", json!({"points": [format!("x:{a}+{b}:20.4,15.4"), [30, 25]], "infer": false, "connect": false})); + let line = ids(&r["curves"])[0].clone(); + let sk = sketch(&s); + let start = sk.resolve_point(&format!("{line}.start")).unwrap(); + assert!(sk.point(start).unwrap().dist(Vec2::new(20.0, 15.0)) < 1e-9); + for name in [a, b] { + let curve = sk.curve_index(&name).unwrap(); + assert!(sk.constraints.iter().any(|k| matches!(k.kind, crate::sketch::ConstraintKind::PointOnCurve { p, c } if p == start && c == curve))); + } + let before_undo = s.doc.to_json(); + run(&mut s, "edit.undo", json!({})); + assert_eq!(sketch(&s).curves.len(), 2); + run(&mut s, "edit.redo", json!({})); + assert_eq!(s.doc.to_json(), before_undo); +} + #[test] fn drawing_at_a_curve_crossing_refuses_hostile_arguments() { let mut s = new_sketch(); @@ -1641,3 +1662,52 @@ fn inward_bulging_arc_pairs_keep_the_gap_dimension() { sk.constraints.iter().map(|c| &c.kind).collect::>() ); } + +/// #9735: the over-constrain conflict list named the refused dimension's own id — the trial +/// sketch already holds the new constraint under the id it would get, and the next constraint +/// added takes that id. The list names only constraints the sketch already has. +#[test] +fn over_constrain_message_leaves_out_the_refused_dimensions_own_id() { + let mut s = new_sketch(); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 20]})); + run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 50})); + // The sketch holds k1..k6: the refused dimension would take k7, which is not yet one of them. + let e = s.execute("sketch.dimension", &json!({"entities": ["l3"], "value": 30})).unwrap_err().to_string(); + assert!(e.contains("would over-constrain the sketch (conflicts with"), "{e}"); + assert!(e.contains("k3") && e.contains("k4") && e.contains("k6"), "{e}"); + assert!(!e.contains("k7"), "{e}: the refused dimension's own id must not be listed"); + // The next constraint takes k7: the id the message must not have burned. + run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": 25})); + assert_eq!(sketch(&s).constraints.last().unwrap().id, "k7"); +} + +/// #9735: "neither entity can move … (they are fixed or fully constrained)" fired for sketches +/// with free degrees of freedom. The refusal now lists the constraints the new one conflicts +/// with by id, as the over-constrain message does. +#[test] +fn conflict_lists_the_constraints_instead_of_claiming_the_entities_are_fixed() { + // A rectangle from (10,10): 4 degrees of freedom, nothing fixed; its bottom side cannot be + // made parallel to its right side — the conflict is with the rectangle's own constraints. + let mut s = new_sketch(); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [10, 10], "p1": [50, 30]})); + let before = serde_json::to_value(sketch(&s)).unwrap(); + let e = s.execute("sketch.constraint.parallel", &json!({"a": "l1", "b": "l2"})).unwrap_err().to_string(); + assert!(!e.contains("fixed or fully constrained"), "{e}"); + // The horizontal on l1 (k1) and the vertical on l2 (k3) are what the parallel conflicts with. + assert!(e.contains("conflicts with k1, k3"), "{e}"); + assert!(!e.contains("k5"), "{e}: the refused constraint's own id must not be listed"); + assert_eq!(serde_json::to_value(sketch(&s)).unwrap(), before, "the sketch does not change"); + // A rectangle drawn from the origin (2 degrees of freedom): the same refusals name the + // constraints they conflict with, not fixed entities. + for (cmd, params) in [ + ("sketch.constraint.parallel", json!({"a": "l1", "b": "l2"})), + ("sketch.constraint.coincident", json!({"a": "p1", "b": "p3"})), + ("sketch.constraint.perpendicular", json!({"a": "l1", "b": "l3"})), + ] { + let mut s = new_sketch(); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 20]})); + let e = s.execute(cmd, ¶ms).unwrap_err().to_string(); + assert!(!e.contains("fixed or fully constrained"), "{cmd} {params}: {e}"); + assert!(e.contains("conflicts with"), "{cmd} {params}: {e}"); + } +} diff --git a/crates/engine/src/cmd/sketch_corner_tests.rs b/crates/engine/src/cmd/sketch_corner_tests.rs index f49402bd5a..09174c30a6 100644 --- a/crates/engine/src/cmd/sketch_corner_tests.rs +++ b/crates/engine/src/cmd/sketch_corner_tests.rs @@ -98,6 +98,59 @@ fn fillet_and_chamfer_corners() { assert!((area(&s) - (a0 - 8.0)).abs() < 1e-6, "{}", area(&s)); } +/// Each chamfer cuts several corners at once (`points`), as a fillet rounds them: every corner +/// alike, in one edit, and a corner that can't be cut, or is named twice, refuses them all. +#[test] +fn chamfers_cut_several_corners() { + let corners = json!(["l1.start", "l1.end", "l2.end", "l3.end"]); + for (cmd, sizes, cut) in [ + ("sketch.chamfer.equal_distance", json!({"distance": 4}), 8.0), + ("sketch.chamfer.two_distance", json!({"distance": 2, "distance2": 6}), 6.0), + ("sketch.chamfer.distance_angle", json!({"distance": 3, "angle": 45}), 4.5), + ] { + let mut s = new_sketch(); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]})); + let mut p = sizes.clone(); + p["points"] = corners.clone(); + let out = run(&mut s, cmd, p); + assert_eq!(curves(&out).len(), 4, "{cmd}: {out}"); + assert_eq!(sk(&s).curves.len(), 8, "{cmd}"); + assert!(solved(&s), "{cmd}"); + assert!((area(&s) - (1200.0 - 4.0 * cut)).abs() < 1e-6, "{cmd}: {}", area(&s)); + } + // A corner named by its two lines, beside one named by its point. + let mut s = new_sketch(); + let c = curves(&run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]}))); + run(&mut s, "sketch.chamfer.equal_distance", json!({"points": [{"a": c[0], "b": c[1]}, "l3.end"], "distance": 4})); + assert!((area(&s) - (1200.0 - 2.0 * 8.0)).abs() < 1e-6, "{}", area(&s)); + // Two lines that share no end are brought to where they meet, beside a corner they already + // make; named again by its point once they meet, that corner is listed twice. + let mut s = new_sketch(); + let la = curves(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]], "connect": false})))[0].clone(); + let lb = curves(&run(&mut s, "sketch.line", json!({"points": [[20, 5], [20, 20]], "connect": false})))[0].clone(); + run(&mut s, "sketch.line", json!({"points": [format!("{lb}.end"), [0, 20]]})); + let e = s.execute("sketch.chamfer.equal_distance", &json!({"points": [{"a": la, "b": lb}, format!("{la}.end")], "distance": 4})); + assert!(e.unwrap_err().to_string().contains("listed twice")); + assert_eq!(sk(&s).curves.len(), 3); + let out = run(&mut s, "sketch.chamfer.equal_distance", json!({"points": [{"a": la, "b": lb}, format!("{lb}.end")], "distance": 4})); + assert_eq!(curves(&out).len(), 2, "{out}"); + assert!(solved(&s)); + let refused = [ + // 20 mm back from both ends of a 30 mm side: the second corner doesn't fit. + json!({"points": corners, "distance": 20}), + // One corner by both of its lines' ends: cut once, the second name would be a new corner. + json!({"points": ["l1.end", "l2.start"], "distance": 4}), + json!({"points": [], "distance": 1}), + ]; + for p in refused { + let mut s = new_sketch(); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]})); + assert!(s.execute("sketch.chamfer.equal_distance", &p).is_err(), "{p}"); + assert_eq!(sk(&s).curves.len(), 4, "{p}"); + assert!((area(&s) - 1200.0).abs() < 1e-6, "{p}"); + } +} + /// A chamfer where the two lines have no corner is refused, as a fillet there is: two lines /// lying on each other made a chamfer line of no length between their cut-back ends (found by a /// local trim fuzzer), and two lines in line with each other have no corner either. @@ -297,3 +350,63 @@ fn a_fillet_of_a_nearly_straight_or_folded_corner_with_dimensions_does_not_panic assert!(sk(&s).points.iter().all(|p| p.pos.is_finite())); } } + +/// Two lines that share no end are brought to where they meet before the corner is rounded or +/// cut (#6912, #7759, #7985). A line that stops short is extended, and one that runs past the +/// other is cut there, losing its shorter piece; the far ends stay. Parallel lines, an end held +/// by other geometry and a dimensioned length are refused, and change nothing. +#[test] +fn a_fillet_or_chamfer_brings_two_separate_lines_to_where_they_meet() { + use solidcraft_geom::Vec2; + use solidcraft_sketch::CurveKind; + let lines = |s: &mut Session, a: [[f64; 2]; 2], b: [[f64; 2]; 2]| { + let la = curves(&run(s, "sketch.line", json!({"points": a, "connect": false})))[0].clone(); + let lb = curves(&run(s, "sketch.line", json!({"points": b, "connect": false})))[0].clone(); + (la, lb) + }; + let ends = |s: &Session, id: &str| { + let k = sk(s); + let CurveKind::Line { a, b } = k.curves[k.curve_index(id).unwrap()].kind else { panic!("{id} is a line") }; + (k.point(a).unwrap(), k.point(b).unwrap()) + }; + let near = |q: Vec2, x: f64, y: f64| q.dist(Vec2::new(x, y)) < 1e-6; + // Stopping short of each other (a gap), and crossing with a short piece past the corner. + for (a, b) in [([[0.0, 0.0], [10.0, 0.0]], [[20.0, 5.0], [20.0, 20.0]]), ([[0.0, 0.0], [24.0, 0.0]], [[20.0, -4.0], [20.0, 20.0]])] { + let mut s = new_sketch(); + let (la, lb) = lines(&mut s, a, b); + let r = run(&mut s, "sketch.fillet", json!({"a": la, "b": lb, "radius": 3})); + assert!(solved(&s), "{r}"); + let k = sk(&s); + let arc = k.curve_index(&curves(&r)[0]).unwrap(); + let CurveKind::Arc { c, .. } = k.curves[arc].kind else { panic!("an arc") }; + assert!(near(k.point(c).unwrap(), 17.0, 3.0), "{:?}", k.point(c)); + let ((a0, a1), (b0, b1)) = (ends(&s, &la), ends(&s, &lb)); + assert!(near(a0, 0.0, 0.0) && near(a1, 17.0, 0.0), "{la}: {a0:?} {a1:?}"); + assert!(near(b0, 20.0, 3.0) && near(b1, 20.0, 20.0), "{lb}: {b0:?} {b1:?}"); + assert_eq!(k.curves.len(), 3, "the two lines and the arc, nothing left past the corner"); + assert!(k.points.iter().all(|q| !near(q.pos, 24.0, 0.0) && !near(q.pos, 20.0, -4.0) && !near(q.pos, 20.0, 0.0)), "{:?}", k.points); + } + // A chamfer the same way. + let mut s = new_sketch(); + let (la, lb) = lines(&mut s, [[0.0, 0.0], [10.0, 0.0]], [[20.0, 5.0], [20.0, 20.0]]); + run(&mut s, "sketch.chamfer.equal_distance", json!({"a": la, "b": lb, "distance": 4})); + assert!(solved(&s)); + let ((_, a1), (b0, _)) = (ends(&s, &la), ends(&s, &lb)); + assert!(near(a1, 16.0, 0.0) && near(b0, 20.0, 4.0), "{a1:?} {b0:?}"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(sk(&s).curves.len(), 2, "undo brings back the two separate lines"); + + // Refused: parallel lines, an end joined to a third line, a dimensioned length. + let mut s = new_sketch(); + let (pa, pb) = lines(&mut s, [[0.0, 0.0], [10.0, 0.0]], [[0.0, 5.0], [10.0, 5.0]]); + let (la, lb) = lines(&mut s, [[0.0, 20.0], [10.0, 20.0]], [[20.0, 25.0], [20.0, 40.0]]); + run(&mut s, "sketch.line", json!({"points": [format!("{la}.end"), [10, 30]], "connect": false})); + let (da, db) = lines(&mut s, [[0.0, 50.0], [10.0, 50.0]], [[20.0, 55.0], [20.0, 70.0]]); + run(&mut s, "sketch.dimension", json!({"entities": [da], "value": 10})); + let before = sk(&s); + for (a, b, why) in [(&pa, &pb, "parallel"), (&la, &lb, "held by other geometry"), (&da, &db, "length is held")] { + let e = s.execute("sketch.fillet", &json!({"a": a, "b": b, "radius": 2})).unwrap_err().to_string(); + assert!(e.contains(why), "{a}, {b}: {e}"); + } + assert_eq!(sk(&s), before, "refusals change nothing"); +} diff --git a/crates/engine/src/cmd/sketch_create.rs b/crates/engine/src/cmd/sketch_create.rs index 39758b08f5..8afe7d695d 100644 --- a/crates/engine/src/cmd/sketch_create.rs +++ b/crates/engine/src/cmd/sketch_create.rs @@ -37,7 +37,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .enabled(in_sketch) .params(concat!("mid: [x,y], point ref or snap (\"mid:\", \"on::x,y\", model vertex/edge); end: [x,y]; construction?", created_doc!())), CommandSpec::new("sketch.arc.tangent", "Tangent Arc", arc_tangent).at("SKETCH", "CREATE").icon("arc_tangent").enabled(in_sketch).params(concat!( - "start: end point of a line or arc (\"l1.end\"), end: [x,y]; curve?: the curve to be tangent to (default: the one ending at start)", + "start: end point of a line or arc (\"l1.end\"), end: [x,y] or an existing point's id (\"l2.start\": the arc ends on that point); curve?: the curve to be tangent to (default: the one ending at start)", created_doc!() )), CommandSpec::new("sketch.circle.two_tangent", "2-Tangent Circle", circle_tan_tan) @@ -819,10 +819,51 @@ pub(super) fn leaving_dir(sk: &Sketch, ci: usize, pi: usize) -> Option { } } +/// The centre of the arc that leaves `start` in direction `dir` and ends at `end`, and whether it +/// runs counter-clockwise. `None` straight ahead (or back), where no arc does. +fn tangent_arc_centre(dir: Vec2, start: Vec2, end: Vec2) -> Option<(Vec2, bool)> { + let n = dir.perp(); + let d = end - start; + let den = 2.0 * n.dot(d); + if den.abs() < 1e-9 * d.len().max(1e-9) || d.len() < 1e-9 { + return None; + } + Some((start + n * (d.len2() / den), den > 0.0)) +} + +/// The arc `sketch.arc.tangent` would draw from point `start` on `curve` to `end`, alone in a +/// sketch of its own for a preview. `None` where the command would refuse. +pub fn tangent_arc_preview(sk: &Sketch, curve: &str, start: &str, end: Vec2) -> Option { + let pi = sk.resolve_point(start)?; + let s0 = sk.point(pi)?; + let (center, ccw) = tangent_arc_centre(leaving_dir(sk, sk.curve_index(curve)?, pi)?, s0, end)?; + if !center.is_finite() { + return None; + } + let mut out = Sketch::new(); + if ccw { + out.add_arc(center, s0, end, [None; 3], None).ok()?; + } else { + out.add_arc(center, end, s0, [None; 3], None).ok()?; + out.curves.first_mut()?.reversed = true; + } + Some(out) +} + fn arc_tangent(s: &mut Session, p: &Value) -> Result { let cmd = "sketch.arc.tangent"; - let end = req_vec2(cmd, p, "end")?; + // An id ends the arc on that point, so a chain can close; coordinates make a new point. + let end_ref = p.get("end").and_then(Value::as_str); + let at = if end_ref.is_some() { None } else { Some(req_vec2(cmd, p, "end")?) }; let (out, info) = edit(s, p, cmd, false, |sk, doc| { + let (end, end_point) = match (end_ref, at) { + (Some(r), _) => { + let q = sk.resolve_point(r).ok_or_else(|| bad(cmd, format!("unknown sketch point `{r}`")))?; + (sk.point(q).ok_or_else(|| bad(cmd, format!("unknown sketch point `{r}`")))?, Some(q)) + } + (None, Some(at)) => (at, None), + (None, None) => return Err(bad(cmd, "missing `end`")), + }; let st = req_parg(sk, doc, p, "start", cmd)?; let picked = st.idx().ok_or_else(|| bad(cmd, "`start` must be the end point of a line or arc"))?; // The picked point, then any other point at the same place: a trimmed arc's end is its own @@ -848,17 +889,11 @@ fn arc_tangent(s: &mut Session, p: &Value) -> Result { }; let t = leaving_dir(sk, ci, pi).ok_or_else(|| bad(cmd, "`start` is not an end of that curve"))?; let s0 = st.pos(); - let n = t.perp(); - let d = end - s0; - let den = 2.0 * n.dot(d); - if den.abs() < 1e-9 * d.len().max(1e-9) || d.len() < 1e-9 { - return Err(bad(cmd, "the end is straight ahead: draw a line instead")); - } - let center = s0 + n * (d.len2() / den); - let a = if den > 0.0 { - sk.add_arc(center, s0, end, [None, Some(pi), None], None)? + let (center, ccw) = tangent_arc_centre(t, s0, end).ok_or_else(|| bad(cmd, "the end is straight ahead: draw a line instead"))?; + let a = if ccw { + sk.add_arc(center, s0, end, [None, Some(pi), end_point], None)? } else { - let a = sk.add_arc(center, end, s0, [None, None, Some(pi)], None)?; + let a = sk.add_arc(center, end, s0, [None, end_point, Some(pi)], None)?; if let Some(c) = sk.curves.get_mut(a) { c.reversed = true; } @@ -959,6 +994,8 @@ fn tangent_circle(s: &mut Session, p: &Value, cmd: &str, n: usize, radius: Optio fn circle_tan_tan(s: &mut Session, p: &Value) -> Result { let cmd = "sketch.circle.two_tangent"; let r = match (crate::params::expr(p, "radius"), num(p, "diameter")) { + // A bare number typed as text reads in the design's unit, as in `sketch.dimension`. + (Some(r), _) if p.get("radius").is_some_and(Value::is_string) => s.doc.with_design_unit(r.as_str(), solidcraft_doc::expr::Kind::Length), (Some(r), _) => r.into_string(), (None, Some(d)) => format!("{}", d / 2.0), _ => return Err(bad(cmd, "needs `radius` or `diameter`")), diff --git a/crates/engine/src/cmd/sketch_drag.rs b/crates/engine/src/cmd/sketch_drag.rs index 0337546f11..f8920ed8bd 100644 --- a/crates/engine/src/cmd/sketch_drag.rs +++ b/crates/engine/src/cmd/sketch_drag.rs @@ -40,6 +40,17 @@ const HALVINGS: usize = 16; const AHEAD: usize = 8; /// An arc whose sweep gets this close (radians) to 0 or a full turn has run into its start. const SWEEP_EPS: f64 = 1e-6; +/// A line or circle longer or wider than this before a step and shorter or narrower than +/// `SHRINK_TO` after it was shrunk to a point by that step. An edit's `shrunk` stops at 1e-5, +/// but a drag's first step shrank a horizontal 20 mm line to 1.05e-5 (#9062): its direction +/// rows (`b.y - a.y`) scale with its length, so the solve stops wherever the length times the +/// tilt is under the tolerance, and the next frames took it the rest of the way. +const SHRINK_FROM: f64 = 1e-3; +const SHRINK_TO: f64 = 1e-4; + +/// A step that moves some point further than this many times the sketch's size and the +/// drag's way has thrown the geometry to a far solution: it is taken as failed. +const FLUNG: f64 = 1000.0; /// What a drag moves, from where it is (`t = 0`) to where the cursor puts it (`t = 1`). enum Path { @@ -169,6 +180,10 @@ pub(super) fn drag_entity( let line_grab = if with.is_empty() { LineGrab::new(sk, e, from) } else { None }; let start = line_grab.and_then(|g| g.point(sk)).or_else(|| if let Ent::Point(q) = e { sk.point(q) } else { None }); let mut frame = live.then(frame_budget); + let reach = FLUNG * (extent(sk) + path.length()).max(1.0); + // The sketch as the drag found it: no step may press one of its lines to no length. + let mut as_found = Geometry::default(); + as_found.take(sk); // Only success matters to these solves: the degrees of freedom of a sketch with the entity // held are not the sketch's. Each is seeded on the last solved state (`last`), so what the // constraints pin down keeps the orientation it had there. A failed or wrapping step is @@ -179,7 +194,9 @@ pub(super) fn drag_entity( let held = |sk: &mut Sketch, last: &Geometry, t: f64, budget: &mut Budget| { path.apply(sk, t); let kept = keep.as_ref().is_some_and(|keep| { - let ok = counted_solve(sk, keep, Some(&last.pos), Analysis::StatusOnly, Some(&mut *budget)).ok() && !last.sweep_wrapped(sk); + let ok = counted_solve(sk, keep, Some(&last.pos), Analysis::StatusOnly, Some(&mut *budget)).ok() + && !last.sweep_wrapped(sk) + && !last.shrank(sk); if !ok { last.restore(sk); path.apply(sk, t); @@ -197,10 +214,13 @@ pub(super) fn drag_entity( solves.work += budget.spent() - spent; solves.cut |= budget.exhausted(); solves.numeric |= budget.numeric_exhausted(); - let outcome = if !ok { + // A step that shrank a line to a point only met its constraints without that line. + let outcome = if !ok || last.shrank(sk) { Step::Failed } else if last.sweep_wrapped(sk) { Step::Wrapped + } else if last.flung(sk, reach) || as_found.squashed(sk) { + Step::Failed } else { return Step::Solved; }; @@ -332,7 +352,11 @@ pub(super) fn drag_entity( let valid = |before: &Sketch, after: &Sketch| { let mut geometry = Geometry::default(); geometry.take(before); - !geometry.sweep_wrapped(after) && solidcraft_sketch::fillets_kept(before, after) + !geometry.sweep_wrapped(after) + && !geometry.shrank(after) + && !geometry.flung(after, reach) + && !as_found.squashed(after) + && solidcraft_sketch::fillets_kept(before, after) }; let followed = match (e, line_grab) { (Ent::Point(q), _) => solidcraft_sketch::move_point_towards(sk, q, to, max_step, frame.as_mut(), valid), @@ -572,6 +596,23 @@ impl Geometry { } } + /// Did the step press a line to (nearly) no length: under 1/1000 of what it had when this + /// geometry was taken? A line with no length has no direction left for the constraints on + /// it, so an end dragged past the point where its line would have to turn over stops there. + fn squashed(&self, sk: &Sketch) -> bool { + self.curves.iter().any(|(kind, _)| { + let CurveKind::Line { a, b } = *kind else { return false }; + let (Some(a0), Some(b0)) = (self.pos.get(a), self.pos.get(b)) else { return false }; + let before = a0.dist(*b0); + before > 1e-3 && sk.point(a).zip(sk.point(b)).is_some_and(|(a1, b1)| a1.dist(b1) < before * 1e-3) + }) + } + + /// Did the step throw some point further than `reach` (or to no number at all)? + fn flung(&self, sk: &Sketch, reach: f64) -> bool { + sk.points.iter().zip(&self.pos).any(|(p, was)| !(p.pos.dist(*was) <= reach)) + } + /// Did the step drive some arc's end through its start: the sweep jumped through 0 / a full /// turn, or ran from clear of them onto one? An arc already that close may still move. /// Measured on the arc's ends as they were before the step (a solve may swap them). @@ -585,6 +626,22 @@ impl Geometry { (s1 - s0).abs() > PI || (clear(s0) && !clear(s1)) }) } + + /// Did the step shrink a line or circle to a point (from longer or wider than `SHRINK_FROM` + /// to under `SHRINK_TO`)? A line with no length has no direction left, so its horizontal, + /// parallel or point-on-line constraints hold anything: a solve took that to put a point it + /// couldn't reach on the line, and the construction line it was on vanished (#9062). + fn shrank(&self, sk: &Sketch) -> bool { + let was = |q: usize| self.pos.get(q).copied(); + self.curves.iter().zip(&sk.curves).any(|((kind, _), now)| match (kind, &now.kind) { + (CurveKind::Line { a, b }, _) => match (was(*a).zip(was(*b)), sk.point(*a).zip(sk.point(*b))) { + (Some((a0, b0)), Some((a1, b1))) => a0.dist(b0) > SHRINK_FROM && !(a1.dist(b1) >= SHRINK_TO), + _ => false, + }, + (CurveKind::Circle { r: r0, .. }, CurveKind::Circle { r: r1, .. }) => *r0 > SHRINK_FROM && !(*r1 >= SHRINK_TO), + _ => false, + }) + } } /// The held solves dragging `e` from `from` to `to` takes (0 when it cannot move): the plan @@ -801,6 +858,29 @@ impl Group<'_> { } } +/// The size of the sketch: the diagonal of its points' box and its circles' diameters. +fn extent(sk: &Sketch) -> f64 { + let (lo, hi) = sk + .points + .iter() + .map(|p| p.pos) + .filter(|p| p.x.is_finite() && p.y.is_finite()) + .fold((Vec2::new(f64::INFINITY, f64::INFINITY), Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY)), |(lo, hi), p| { + (Vec2::new(lo.x.min(p.x), lo.y.min(p.y)), Vec2::new(hi.x.max(p.x), hi.y.max(p.y))) + }); + let radius = sk + .curves + .iter() + .filter_map(|c| match c.kind { + CurveKind::Circle { r, .. } | CurveKind::Ellipse { r, .. } => Some(r.abs()), + _ => None, + }) + .filter(|r| r.is_finite()) + .fold(0.0, f64::max); + let diagonal = if lo.x <= hi.x { lo.dist(hi) } else { 0.0 }; + diagonal + 2.0 * radius +} + /// The smallest size that can make a constrained shape take another solution (of the geometry /// in `group`, or of the whole sketch), or infinity when there is none: every length the /// constraints hold. That is driving length dimensions (offsets along an axis or from a line diff --git a/crates/engine/src/cmd/sketch_equation.rs b/crates/engine/src/cmd/sketch_equation.rs index 5aac5da3eb..1ab4bf5b27 100644 --- a/crates/engine/src/cmd/sketch_equation.rs +++ b/crates/engine/src/cmd/sketch_equation.rs @@ -5,7 +5,7 @@ use serde_json::{Value, json}; use solidcraft_doc::sketch_equation::{self, DEFAULT_TOLERANCE, EquationFit}; -use solidcraft_sketch::{EquationCurve, Sketch}; +use solidcraft_sketch::{EquationCurve, Sketch, VariableUnit}; use super::sketch::{edit, mark_construction}; use super::{CommandSpec, in_sketch}; @@ -31,8 +31,10 @@ pub static COMMANDS: &[CommandSpec] = &[ .params(concat!( "x: x(t), y: y(t): formulas of t in the design's expression language and parameters (\"r * (cos(t) + t * sin(t))\"); or explicit: true and y: f(x) alone (no x); \ start, end: the range of t (or of x): a number or an expression (\"2 * pi\", \"360 deg\" is 2·pi, a length is in the design's unit); \ - tolerance?: mm (default 0.01): how far the curve may stray from the formulas (0.0001 to 10); construction?: bool. \ + tolerance?: mm (default 0.01): how far the curve may stray from the formulas (0.0001 to 10); construction?: bool; \ + variable_unit?: \"number\" (default) or \"range\". \ The variable is a plain number: sin(t) reads it in radians; a result without a unit is in the design's length unit. \ + With variable_unit \"range\" it is a length, so a ^ 2 / (4 * t) is a length when a is one: start and end must both be lengths, or one a length and the other a plain number, which is then in the design's unit. \ Makes a fit point spline through fixed points, as many as the tolerance needs (up to 500), closed smoothly when it ends where it starts; \ the points are placed again from the formulas whenever the parameters change. Returns curve, points (how many), closed, deviation (mm)", created_doc!() @@ -43,7 +45,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .contract(&EDIT_CONTRACT) .output(output_schema_for::) .params( - "curve: an equation curve's id; x?, y?, explicit?, start?, end?, tolerance?: as in sketch.equation_curve, each kept when left out (explicit: true drops x; false needs x). \ + "curve: an equation curve's id; x?, y?, explicit?, start?, end?, tolerance?, variable_unit?: as in sketch.equation_curve, each kept when left out (explicit: true drops x; false needs x). \ The points are chosen again for the new formulas; the curve's start point (and the end point of an open curve) keeps its id, so geometry joined there stays joined. Returns curve, points, closed, deviation", ), ]; @@ -81,6 +83,13 @@ fn explicit(p: &Value, cmd: &str) -> Result> { } } +fn variable_unit(p: &Value, cmd: &str) -> Result> { + match p.get("variable_unit") { + None | Some(Value::Null) => Ok(None), + Some(v) => v.as_str().and_then(VariableUnit::parse).map(Some).ok_or_else(|| bad(cmd, "`variable_unit` must be \"number\" or \"range\"")), + } +} + /// The equation a command's parameters give, over `old` (each left out kept). fn equation(p: &Value, cmd: &str, old: Option<&EquationCurve>) -> Result { let x = text(p, cmd, "x")?; @@ -111,6 +120,7 @@ fn equation(p: &Value, cmd: &str, old: Option<&EquationCurve>) -> Result String { + run(s, "parameters.add", json!({"name": "a", "expression": "10 mm"})); + run(s, "sketch.create", json!({"plane": "XY", "name": "S"})); + let r = run(s, "sketch.equation_curve", json!({"x": "t", "y": "a ^ 2 / (4 * t)", "start": "a", "end": "3 * a", "variable_unit": "range"})); + assert_eq!(r["closed"], false, "{r}"); + let id = r["curve"].as_str().unwrap().to_string(); + run(s, "sketch.finish", json!({})); + no_trouble(s); + id +} + +#[test] +fn a_variable_in_its_ranges_unit_follows_the_parameter_and_the_design_unit() { + let mut s = Session::default(); + // Without the flag this very formula is refused, as it always was. + run(&mut s, "parameters.add", json!({"name": "b", "expression": "10 mm"})); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Off"})); + let why = s.execute("sketch.equation_curve", &json!({"x": "t", "y": "b ^ 2 / (4 * t)", "start": "b", "end": "3 * b"})).unwrap_err().to_string(); + assert!(why.contains("length^2"), "{why}"); + run(&mut s, "sketch.finish", json!({})); + + let id = range_unit_curve(&mut s); + assert_parabola(&s, &id, 10.0); + run(&mut s, "parameters.change", json!({"name": "a", "expression": "20 mm"})); + no_trouble(&s); + assert_parabola(&s, &id, 20.0); + // In millimetres the parameter's length does not depend on the design's unit, nor the curve. + run(&mut s, "document.units", json!({"units": "in"})); + no_trouble(&s); + assert_parabola(&s, &id, 20.0); + // A parameter given in inches, in an inch design. + run(&mut s, "parameters.change", json!({"name": "a", "expression": "2 in"})); + no_trouble(&s); + assert_parabola(&s, &id, 50.8); +} + +/// A plain end is in the design's unit, so it moves when the unit changes (#9333). +#[test] +fn a_plain_end_of_the_range_takes_the_design_unit() { + let mut s = Session::default(); + run(&mut s, "parameters.add", json!({"name": "a", "expression": "100 mm"})); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"})); + let r = run(&mut s, "sketch.equation_curve", json!({"x": "t", "y": "t / 2", "start": 1, "end": "a", "variable_unit": "range"})); + let id = r["curve"].as_str().unwrap().to_string(); + run(&mut s, "sketch.finish", json!({})); + assert!((curve_points(&s, &id)[0] - Vec2::new(1.0, 0.5)).len() < 1e-9); + run(&mut s, "document.units", json!({"units": "in"})); + no_trouble(&s); + let pts = curve_points(&s, &id); + assert!((pts[0] - Vec2::new(25.4, 12.7)).len() < 1e-9, "{:?}", pts[0]); + assert!((*pts.last().unwrap() - Vec2::new(100.0, 50.0)).len() < 1e-9); +} + +#[test] +fn the_refused_range_says_why_and_changes_nothing() { + let mut s = Session::default(); + run(&mut s, "parameters.add", json!({"name": "a", "expression": "10 mm"})); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"})); + let before = s.doc.to_json(); + for (start, end) in [("a", "90 deg"), ("90 deg", "a"), ("0", "1")] { + let p = json!({"x": "t", "y": "t", "start": start, "end": end, "variable_unit": "range"}); + let why = s.execute("sketch.equation_curve", &p).unwrap_err().to_string(); + assert!( + why.contains("with the variable in its range's unit, start and end must both be lengths, or one a length and the other a plain number"), + "{p}: {why}" + ); + assert_eq!(s.doc.to_json(), before, "{p} changed the design"); + } + for bad in [json!("mm"), json!(1), json!(true), json!({}), json!("Range")] { + assert!(s.execute("sketch.equation_curve", &json!({"x": "t", "y": "t", "start": 0, "end": "a", "variable_unit": bad})).is_err(), "{bad}"); + } +} + +/// A design file is hostile (#9333): a range the rule refuses is reported on the sketch, and a +/// value that means nothing refuses the file. Neither crashes. +#[test] +fn a_hostile_saved_flag_or_range_is_an_error_not_a_crash() { + let mut s = Session::default(); + range_unit_curve(&mut s); + let saved = s.doc.to_json(); + assert!(saved.contains("3 * a") && saved.contains("\"range\""), "{saved}"); + let angle = saved.replace("3 * a", "90 deg"); + let r = Session::new(solidcraft_doc::Document::from_json(&angle).unwrap()); + let res = r.model.results.iter().find(|r| r.name == "S").unwrap(); + assert!(res.error.as_deref().is_some_and(|e| e.contains("equation curve") && e.contains("must both be lengths")), "{:?}", res.error); + assert!(solidcraft_doc::Document::from_json(&saved.replace("\"range\"", "\"furlong\"")).is_err()); +} + +#[test] +fn an_edit_keeps_the_flag_until_told_and_a_saved_design_keeps_it() { + use solidcraft_sketch::VariableUnit; + let mut s = Session::default(); + let id = range_unit_curve(&mut s); + let f = s.doc.find_feature("S").unwrap().id; + // The file says it, and reopening follows it. + let saved = s.doc.to_json(); + assert!(saved.contains("variable_unit"), "not saved"); + let mut r = Session::new(solidcraft_doc::Document::from_json(&saved).unwrap()); + no_trouble(&r); + assert_eq!(r.doc.sketch(f).unwrap().equation_of(&id).unwrap().variable_unit, VariableUnit::Range); + assert_parabola(&r, &id, 10.0); + run(&mut r, "parameters.change", json!({"name": "a", "expression": "20 mm"})); + assert_parabola(&r, &id, 20.0); + // An edit that leaves it out keeps it. + run(&mut s, "sketch.edit", json!({"sketch": "S"})); + run(&mut s, "sketch.equation_curve.edit", json!({"curve": id, "tolerance": 0.005})); + assert_eq!(s.doc.sketch(f).unwrap().equation_of(&id).unwrap().variable_unit, VariableUnit::Range); + // Off again, that formula is refused and the curve stays as it was. + assert!(s.execute("sketch.equation_curve.edit", &json!({"curve": id, "variable_unit": "number"})).is_err()); + assert_eq!(s.doc.sketch(f).unwrap().equation_of(&id).unwrap().variable_unit, VariableUnit::Range); + run(&mut s, "sketch.equation_curve.edit", json!({"curve": id, "y": "t / 2", "start": 0, "end": 10, "variable_unit": "number"})); + assert_eq!(s.doc.sketch(f).unwrap().equation_of(&id).unwrap().variable_unit, VariableUnit::Number); + assert!(!s.doc.to_json().contains("variable_unit")); +} + +/// A design written without the flag (#9333): nothing in the file changes, and its curve is what +/// it was, with the variable a plain number over a range in length. +#[test] +fn a_design_saved_without_the_flag_reopens_the_same() { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"})); + let r = run(&mut s, "sketch.equation_curve", json!({"x": "t", "y": "t * t / 20", "start": "0 mm", "end": "40 mm"})); + let id = r["curve"].as_str().unwrap().to_string(); + run(&mut s, "sketch.finish", json!({})); + let saved = s.doc.to_json(); + assert!(!saved.contains("variable_unit"), "a curve without the flag writes no new key"); + let want = curve_points(&s, &id); + assert!((*want.last().unwrap() - Vec2::new(40.0, 80.0)).len() < 1e-9); + // The format before this change, as the last release wrote it. + let mut old: Value = serde_json::from_str(&saved).unwrap(); + old["format"] = json!(solidcraft_doc::format::format_string(solidcraft_doc::format::FORMAT - 1)); + if let Some(o) = old.as_object_mut() { + o.remove("format_pending"); + } + let r = Session::new(solidcraft_doc::Document::from_json(&old.to_string()).unwrap()); + no_trouble(&r); + assert_eq!(curve_points(&r, &id), want); +} diff --git a/crates/engine/src/cmd/sketch_face.rs b/crates/engine/src/cmd/sketch_face.rs index 2742b8ada6..3473854276 100644 --- a/crates/engine/src/cmd/sketch_face.rs +++ b/crates/engine/src/cmd/sketch_face.rs @@ -275,6 +275,27 @@ pub(in crate::cmd) fn capture_positions(s: &mut Session, plane: &PlaneRef) -> Re Ok(()) } +/// The component and placement (occurrence path, empty for the design) whose body a face pick +/// `value` lies on: the pick's `path`, else its `body`'s first placement, else the exact +/// attachment `captured` from it. `None` when none of them names one (a sheet metal panel +/// face picked by its point alone, or a fixed plane picked by its point alone). +pub(in crate::cmd) fn pick_owner(s: &Session, value: &Value, captured: &PlaneRef, cmd: &str) -> Result)>> { + let form = face_pick_form(value).map_err(|why| bad(cmd, why))?; + if let Some(path) = form.path { + if path.is_empty() { + return Ok(Some((0, path))); + } + let component = s.occurrence_placement(&path)?.component; + return Ok(Some((component, path))); + } + if let Some(body) = form.body { + let state = s.model.state(); + let doc = s.placed_document(); + return Ok(state.body(body).map(|body| doc.model_body_component(body)).map(|component| (component, doc.first_path(component)))); + } + Ok(anchor(captured).map(|anchor| (anchor.component, anchor.path.clone()))) +} + #[cfg(test)] #[path = "sketch_face_tests.rs"] mod tests; diff --git a/crates/engine/src/cmd/sketch_face_tests.rs b/crates/engine/src/cmd/sketch_face_tests.rs index f6d195c991..ae9c880658 100644 --- a/crates/engine/src/cmd/sketch_face_tests.rs +++ b/crates/engine/src/cmd/sketch_face_tests.rs @@ -792,3 +792,67 @@ fn a_sketch_on_the_mirrored_half_of_a_combined_body_sits_on_that_half() { assert!((plane.origin - point).dot(normal).abs() < 1e-6, "{name}: origin {:?}", plane.origin); } } +/// The sensor enclosure's lid rests on the case, and its lip, joined below it, parts its underside +/// into a ring and a panel. The ring is named after the case's top it rests on, so the lid's cap +/// certificate finds only the panel: the ring is another piece of the same face and takes a sketch +/// attached by that certificate, as it did before #8278 (#9465, nightly action matrix). +#[test] +fn a_sketch_on_the_ring_of_a_lid_underside_parted_by_its_lip_keeps_the_cap() { + let mut s = Session::default(); + let script: Value = serde_json::from_str(include_str!("../../../../examples/acceptance/sensor_enclosure.json")).unwrap(); + s.run_script(&script).unwrap(); + let world = s.world_state(); + let lid = world.body("Lid").unwrap(); + let names = solidcraft_doc::naming::face_names(lid); + let down: Vec = (0..names.len()) + .filter(|index| lid.body.planar_face(*index).is_some_and(|plane| plane.normal().dot(-Vec3::Z) > 0.999 && (plane.origin.z - 25.).abs() < 1e-6)) + .collect(); + assert!(down.len() > 1, "the lip parts the lid's underside: {down:?}"); + let ring = *down.iter().find(|index| names[**index] == "F2:top").unwrap_or_else(|| panic!("the ring, among {names:?}")); + let pick = json!({"body":"Lid","index":ring,"face":[12.0, 29.266_666_666_665, 25.0]}); + drop(world); + let id = run(&mut s, "sketch.create", json!({"name":"On ring","plane":pick,"project_edges":false}))["sketch"].as_u64().unwrap(); + run(&mut s, "sketch.finish", json!({})); + let plane = s.model.state().sketch(id).unwrap().plane; + assert!(plane.normal().dot(-Vec3::Z) > 0.999 && (plane.origin.z - 25.).abs() < 1e-6, "{plane:?}"); + // The attachment survives an edit that widens the lid. + run(&mut s, "parameters.change", json!({"name": "board_w", "expression": "(60 mm) * 1.1"})); + assert!(timeline_errors(&s).is_empty(), "{:?}", timeline_errors(&s)); + let plane = s.model.state().sketch(id).unwrap().plane; + assert!(plane.normal().dot(-Vec3::Z) > 0.999 && (plane.origin.z - 25.).abs() < 1e-6, "{plane:?}"); +} + +/// `activate_part` (#10215): a sketch on a face of another component's body is made in that +/// component, which stays active; without it the sketch belongs to the active component. +#[test] +fn activate_part_makes_the_sketch_in_the_faces_component() { + for activate in [true, false] { + let mut s = Session::default(); + let part = run(&mut s, "component.create", json!({"name":"Part","parent":0,"grounded":false})); + let (component, occurrence) = (part["component"].as_u64().unwrap(), part["occurrence"].as_u64().unwrap()); + run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base"})); + run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[30,0,0]})); + run(&mut s, "component.activate", json!({"component":0})); + let mut pick = top_pick(&s, "Base"); + pick["path"] = json!([occurrence]); + let out = run(&mut s, "sketch.create", json!({"name":"OnFace","plane":pick,"activate_part":activate,"project_edges":false})); + let id = out["sketch"].as_u64().unwrap(); + let owner = s.doc.feature(id).unwrap().component; + if activate { + assert_eq!(owner, component); + assert_eq!((s.active_component, s.active_occurrence_path.clone()), (component, Some(vec![occurrence]))); + assert_eq!(out["activated"], json!({"component": component, "path": [occurrence]})); + let solidcraft_doc::FeatureKind::Sketch { plane, .. } = &s.doc.feature(id).unwrap().kind else { panic!("not sketch") }; + assert_eq!(super::anchor(plane).unwrap().author_path, vec![occurrence]); + } else { + assert_eq!(owner, 0); + assert_eq!(s.active_component, 0); + assert!(out.get("activated").is_none()); + } + let world = s.world_state(); + let plane = world.sketch(id).unwrap().plane; + assert!((plane.origin.z - 10.).abs() < 1e-6 && plane.normal().z > 1. - 1e-9, "{activate}: {plane:?}"); + draw(&mut s); + assert!(s.model.result(id).unwrap().error.is_none()); + } +} diff --git a/crates/engine/src/cmd/sketch_import.rs b/crates/engine/src/cmd/sketch_import.rs index dc6a8c77a9..a8d80d3326 100644 --- a/crates/engine/src/cmd/sketch_import.rs +++ b/crates/engine/src/cmd/sketch_import.rs @@ -43,7 +43,7 @@ pub fn drawing_import_command(path: &str) -> Option<&'static str> { } } -fn source_text(p: &Value, cmd: &str) -> Result { +fn source_text(p: &Value, cmd: &str, svg: bool) -> Result { if let Some(t) = str_(p, "text") { if t.len() > MAX_DRAWING_BYTES { return Err(bad(cmd, "the drawing is too large")); @@ -52,9 +52,22 @@ fn source_text(p: &Value, cmd: &str) -> Result { } let path = str_(p, "path").filter(|x| !x.trim().is_empty() && x.len() < 4096).ok_or_else(|| bad(cmd, "give `path` or `text`"))?; let bytes = solidcraft_io::vfs::read(path, MAX_DRAWING_BYTES).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + if svg { + return solidcraft_io::decode_svg(&bytes).map_err(|e| drawing_error(p, svg, e.to_string())); + } Ok(String::from_utf8_lossy(&bytes).to_string()) } +/// A problem with the drawing file's contents (not with the command's parameters), named after +/// the file so the message says which drawing it is about (#9641). +fn drawing_error(p: &Value, svg: bool, msg: String) -> EngineError { + let name = str_(p, "path") + .and_then(|x| std::path::Path::new(x).file_name()) + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| if svg { "SVG" } else { "DXF" }.to_string()); + EngineError::Other(format!("{name}: {msg}")) +} + /// Add imported geometry to a sketch, sharing end points that meet. /// Adds the geometry; end points closer than `q` (mm) become one point, so drawings whose /// ends almost meet still make closed profiles. @@ -168,9 +181,16 @@ fn bounds_of(geom: &[Geom2]) -> Option<(Vec2, Vec2)> { (lo.x.is_finite() && lo.y.is_finite() && hi.x.is_finite() && hi.y.is_finite()).then_some((lo, hi)) } -fn read_drawing(p: &Value, cmd: &str, svg: bool) -> Result> { - let text = source_text(p, cmd)?; - if svg { solidcraft_io::read_svg(&text) } else { solidcraft_io::read_dxf(&text) }.map_err(|e| bad(cmd, e.to_string())) +/// The drawing's geometry and the box around it; a drawing with none is an error that says why +/// (#9641). +fn read_drawing(p: &Value, cmd: &str, svg: bool) -> Result<(Vec, (Vec2, Vec2))> { + let text = source_text(p, cmd, svg)?; + let geom = if svg { solidcraft_io::read_svg(&text) } else { solidcraft_io::read_dxf(&text) }.map_err(|e| drawing_error(p, svg, e.to_string()))?; + let Some(bounds) = bounds_of(&geom) else { + let why = if svg { solidcraft_io::svg_nothing_to_import(&text) } else { "the DXF has no entities SolidCraft reads".to_string() }; + return Err(drawing_error(p, svg, why)); + }; + Ok((geom, bounds)) } /// How big a drawing is in its own file, to size it before it is inserted. @@ -181,22 +201,18 @@ fn drawing_bounds(_s: &mut Session, p: &Value) -> Result { Some(f) => !f.eq_ignore_ascii_case("dxf"), None => !name_is_dxf, }; - let geom = read_drawing(p, cmd, svg)?; - let (lo, hi) = bounds_of(&geom).ok_or_else(|| bad(cmd, "the drawing has no geometry SolidCraft reads"))?; + let (_, (lo, hi)) = read_drawing(p, cmd, svg)?; Ok(json!({"min": [lo.x, lo.y], "max": [hi.x, hi.y], "width": hi.x - lo.x, "height": hi.y - lo.y})) } fn insert(s: &mut Session, p: &Value, cmd: &str, svg: bool) -> Result { - let geom = read_drawing(p, cmd, svg)?; - if geom.is_empty() { - return Err(bad(cmd, "the drawing has no geometry SolidCraft reads")); - } + let (geom, (lo, hi)) = read_drawing(p, cmd, svg)?; // A size in millimetres wins over a factor; the aspect ratio is always kept. - let natural = bounds_of(&geom).map(|(lo, hi)| (hi.x - lo.x, hi.y - lo.y)); + let natural = (hi.x - lo.x, hi.y - lo.y); let sized = |key: &str, of: fn(&(f64, f64)) -> f64| -> Result> { let Some(v) = p.get(key) else { return Ok(None) }; let want = v.as_f64().filter(|w| w.is_finite() && *w > 0.0).ok_or_else(|| bad(cmd, format!("`{key}` must be a positive number (mm)")))?; - let have = natural.as_ref().map(of).filter(|h| *h > 1e-9).ok_or_else(|| bad(cmd, format!("the drawing has no {key} to size")))?; + let have = Some(of(&natural)).filter(|h| *h > 1e-9).ok_or_else(|| bad(cmd, format!("the drawing has no {key} to size")))?; Ok(Some(want / have)) }; let from_size = match sized("width", |n| n.0)? { @@ -211,7 +227,7 @@ fn insert(s: &mut Session, p: &Value, cmd: &str, svg: bool) -> Result { let (sin, cos) = angle.sin_cos(); let pivot = match str_(p, "align") { None | Some("origin") => Vec2::ZERO, - Some("center") => bounds_of(&geom).map(|(lo, hi)| (lo + hi) * 0.5).unwrap_or(Vec2::ZERO), + Some("center") => (lo + hi) * 0.5, Some(_) => return Err(bad(cmd, "`align` must be \"origin\" or \"center\"")), }; let off = p.get("at").and_then(vec2).unwrap_or(Vec2::ZERO); @@ -355,6 +371,33 @@ mod tests { } } + /// #9641: an SVG with nothing SolidCraft can draw (here only an embedded picture) is refused + /// with a message naming the file and saying why, not "invalid parameters … no geometry"; a + /// UTF-16 SVG file inserts. + #[test] + fn an_svg_with_nothing_to_import_says_why() { + let dir = std::env::temp_dir().join(format!("solidcraft-9641-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let picture = dir.join("logo.svg"); + std::fs::write( + &picture, + r#""#, + ) + .unwrap(); + let mut s = Session::default(); + for cmd in ["sketch.insert_svg", "sketch.drawing_bounds"] { + let e = s.execute(cmd, &json!({"path": picture.to_string_lossy(), "align": "center"})).unwrap_err().to_string(); + assert!(e.starts_with("logo.svg: ") && e.contains("embedded picture") && !e.contains("invalid parameters"), "{cmd}: {e}"); + } + assert!(s.active_sketch.is_none(), "a refused insert leaves no empty sketch behind"); + let square = r#""#; + let utf16 = dir.join("square.svg"); + std::fs::write(&utf16, [0xFF, 0xFE].into_iter().chain(square.encode_utf16().flat_map(u16::to_le_bytes)).collect::>()).unwrap(); + let r = s.execute("sketch.insert_svg", &json!({"path": utf16.to_string_lossy()})).unwrap(); + assert_eq!(r["curves"], 4, "{r}"); + let _ = std::fs::remove_dir_all(&dir); + } + /// #8155: an elliptical arc of a DXF (as Fusion writes one) comes into the sketch as exact /// conics, and the drawing's size is the arc's own, not the box round the conics' apexes. #[test] diff --git a/crates/engine/src/cmd/sketch_modify.rs b/crates/engine/src/cmd/sketch_modify.rs index 29b4ac42c0..9704723293 100644 --- a/crates/engine/src/cmd/sketch_modify.rs +++ b/crates/engine/src/cmd/sketch_modify.rs @@ -5,10 +5,10 @@ use std::f64::consts::{FRAC_PI_2, PI, TAU}; use serde_json::{Value, json}; use solidcraft_doc::ProfileSel; -use solidcraft_geom::Vec2; -use solidcraft_sketch::{ConstraintKind, Curve, CurveKind, Shape, Sketch, intersections, intersections_within}; +use solidcraft_geom::{Plane, Vec2}; +use solidcraft_sketch::{ConstraintKind, Curve, CurveKind, Shape, Sketch, Wire, WireSpace, intersections, intersections_within}; -use super::sketch::{add_c, edit, ids_of, line_or_axis_ref, solve_current}; +use super::sketch::{add_c, coincident_points, edit, ids_of, line_or_axis_ref, solve_current}; use super::sketch_store; use super::{CommandSpec, in_sketch}; use crate::params::{bad, bool_, expr, num, req_expr, req_vec2, str_, string_list, vec2, vec2_list}; @@ -24,29 +24,29 @@ pub static COMMANDS: &[CommandSpec] = &[ .at("SKETCH", "MODIFY") .icon("sketch_fillet") .enabled(in_sketch) - .params(concat!("point: corner shared by two curves (\"l1.end\") | points: [corners] (all rounded alike: one radius, the rest equal; a corner is a point reference or {a, b}, the two curves meeting there) | a, b: two lines or arcs meeting at a corner; radius: expr. A corner that dimensions or constraints measure to stays as a virtual sharp corner on both curves' extensions (`virtual_corners`; the moved ones in `moved_to_virtual_corner`)", created_doc!())), + .params(concat!("point: corner shared by two curves (\"l1.end\") | points: [corners] (all rounded alike: one radius, the rest equal; a corner is a point reference or {a, b}, the two curves meeting there) | a, b: two lines or arcs meeting at a corner (two lines that share no end are first brought to where they meet: the nearer end of a line that stops short moves there, and a line that runs past is cut there, losing its shorter piece); radius: expr. A corner that dimensions or constraints measure to stays as a virtual sharp corner on both curves' extensions (`virtual_corners`; the moved ones in `moved_to_virtual_corner`)", created_doc!())), CommandSpec::new("sketch.chamfer.equal_distance", "Equal Distance Chamfer", chamfer_equal) .at("SKETCH", "MODIFY") .icon("sketch_chamfer") .enabled(in_sketch) - .params(concat!("point | a, b: a corner of two lines; distance", created_doc!())), + .params(concat!("point | points: [corners] (every corner cut alike; a corner is a point reference or {a, b}) | a, b: a corner of two lines (two that share no end are first brought to where they meet, as in sketch.fillet); distance", created_doc!())), CommandSpec::new("sketch.chamfer.distance_angle", "Distance and Angle Chamfer", chamfer_dist_angle) .at("SKETCH", "MODIFY") .icon("sketch_chamfer_angle") .enabled(in_sketch) - .params(concat!("point | a, b: a corner of two lines; distance: along a, angle: deg from a", created_doc!())), + .params(concat!("point | points: [corners] (every corner cut alike; a corner is a point reference or {a, b}, and a point's `a` is the first of its two lines in curve order) | a, b: a corner of two lines (two that share no end are first brought to where they meet, as in sketch.fillet); distance: along a, angle: deg from a", created_doc!())), CommandSpec::new("sketch.chamfer.two_distance", "Two Distance Chamfer", chamfer_two) .at("SKETCH", "MODIFY") .icon("sketch_chamfer_two_distances") .enabled(in_sketch) - .params(concat!("point | a, b: a corner of two lines; distance: along a, distance2: along b", created_doc!())), + .params(concat!("point | points: [corners] (every corner cut alike; a corner is a point reference or {a, b}, and a point's `a` is the first of its two lines in curve order) | a, b: a corner of two lines (two that share no end are first brought to where they meet, as in sketch.fillet); distance: along a, distance2: along b", created_doc!())), CommandSpec::new("sketch.offset", "Offset", offset) .at("SKETCH", "MODIFY") .icon("offset") .key("O") .enabled(in_sketch) .params(concat!( - "curves: [ids], distance: number in mm or length expression (creates an editable dimension; positive = left of the chain, or toward `side`), side?: [x,y], chain?: bool (default true: the connected chain); ", + "curves: [ids], distance: number in mm or length expression (creates an editable dimension; positive = left of the chain, or toward `side`), side?: [x,y], chain?: bool (default true: the connected chain, including ends that only coincide). A corner is trimmed back to the crossing when the offset runs past it, and extended until the offset curves intersect when the corner falls short; ", "direction?: one_side|symmetric (default one_side) or symmetric?: bool (the same distance each way; no `side`, whose job it takes over), ", "distance2?: positive length expression (Two Sides: an independent distance the other way, as for `solid.extrude`). Each side is its own offset association, listed in `sides`; flip?: bool reverses the primary side; `param`/`param2` name the driving dimensions", created_doc!() @@ -92,7 +92,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .at("SKETCH", "MODIFY") .icon("sketch_move") .enabled(in_sketch) - .params(concat!("entities: [ids], translate?: [dx,dy], angle?: deg, center?: [x,y], copy?: bool, rotate_constraints?: bool (default true; false keeps constraints unchanged); a rotation turns the constraints tied to the sketch axes when all their geometry moves (horizontal and vertical relations and dimensions swap at odd quarter turns and stay at half turns, line angles turn with the line) and removes the horizontal and vertical ones that cannot hold at other angles; picking a text outline or linked point transforms its whole text source once, and copies remain editable text", created_doc!())), + .params(concat!("entities: [ids], translate?: [dx,dy], angle?: deg, center?: [x,y], copy?: bool, rotate_constraints?: bool (default true; false keeps constraints unchanged); a rotation turns the constraints tied to the sketch axes when all their geometry moves (horizontal and vertical relations and dimensions swap at odd quarter turns and stay at half turns, line angles turn with the line) and removes the horizontal and vertical ones that cannot hold at other angles; picking a text outline or linked point transforms its whole text source once, and copies remain editable text; copy also accepts drawn 3D curves, preserving exact arcs and height above the sketch plane", created_doc!())), CommandSpec::new("sketch.mirror", "Mirror", mirror) .at("SKETCH", "PATTERN") .icon("sketch_mirror") @@ -608,6 +608,10 @@ fn break_cmd(s: &mut Session, p: &Value) -> Result { doc.prune_model_params(); return Ok(r["curves"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default()); } + // Its new ends would follow the source curves as trim's and extend's would: a broken + // offset arc came out in three pieces that went the long way round, and the solve + // moved curves the break never touched. + refuse_offset_ends(sk, ci, "break", cmd)?; let sh = sk.shape(ci).ok_or_else(|| bad(cmd, "curve"))?; let (ts, cutters): (Vec, Vec>) = if let Some(list) = &points { let mut v: Vec = list.iter().map(|q| sh.param(sh.project(*q))).collect(); @@ -947,11 +951,19 @@ fn stop_point(sk: &Sketch, ci: usize, q: Vec2) -> Option { } /// Add the line an extension needs, from the growing end to the stop: the sketch's own point -/// when the stop already is one, a new point otherwise. -fn add_line_extension(sk: &mut Sketch, end: usize, q: Vec2, stop: Option) -> Result { +/// when the stop already is one, a new point otherwise. A new point stopped in the middle of a +/// curve is pinned onto it, as the free-end path pins its end, so the extension is attached to +/// the curve it stops on instead of lying loose over it. +fn add_line_extension(sk: &mut Sketch, end: usize, q: Vec2, stop: Option, cutter: Option) -> Result { let b = match stop { Some(p) => p, - None => sk.add_point(q, None)?, + None => { + let p = sk.add_point(q, None)?; + if let Some(cutter) = cutter { + pin(sk, p, cutter)?; + } + p + } }; sk.add_curve(CurveKind::Line { a: end, b }, None)?; Ok(sk.points.get(b).map(|x| x.id.clone()).unwrap_or_default()) @@ -1076,7 +1088,7 @@ fn grow_line(sk: &mut Sketch, ci: usize, aim: LineAim) -> Result { // The end joined to other geometry stays where it is: add a line from it. if !neighbors.is_empty() { - return add_line_extension(sk, end, q, stop); + return add_line_extension(sk, end, q, stop, cutter); } // An end held by a constraint, or lying on a circle or arc, stays where it is too: the @@ -1096,7 +1108,9 @@ fn grow_line(sk: &mut Sketch, ci: usize, aim: LineAim) -> Result { // Geometric check: does the endpoint lie on any circle or arc? let ep = sk.point(end).unwrap_or_default(); sk.curves.iter().any(|curve| match curve.kind { - CurveKind::Circle { c, r } => (ep - sk.point(c).unwrap_or_default()).len().abs() - r < 1e-6, + // On the circle means its distance to the centre equals the radius: a point merely + // *inside* the circle is not held by it. + CurveKind::Circle { c, r } => ((ep - sk.point(c).unwrap_or_default()).len() - r).abs() < TOL, // This is an arc's own start or end: it sits on that arc. CurveKind::Arc { a, b, .. } => ep.dist(sk.point(a).unwrap_or_default()) < 1e-6 || ep.dist(sk.point(b).unwrap_or_default()) < 1e-6, _ => false, @@ -1104,7 +1118,7 @@ fn grow_line(sk: &mut Sketch, ci: usize, aim: LineAim) -> Result { }; if end_is_held { - return add_line_extension(sk, end, q, stop); + return add_line_extension(sk, end, q, stop, cutter); } // A free end moves along instead, if the solver lets it. The attempt is made on a copy, @@ -1134,7 +1148,7 @@ fn grow_line(sk: &mut Sketch, ci: usize, aim: LineAim) -> Result { return Ok(sk.points.get(end).map(|x| x.id.clone()).unwrap_or_default()); } // The solve could not put the end where it belongs: add a new line instead. - add_line_extension(sk, end, q, stop) + add_line_extension(sk, end, q, stop, cutter) } /// The two-curve form of `sketch.extend`: `curves` names exactly two curves, `at` optionally one @@ -1395,21 +1409,31 @@ fn weld_corner(sk: &mut Sketch, p: &Value) { let Some(mut q) = q else { return }; // Each pass welds one partner; a pass removes a constraint, so this ends. for _ in 0..sk.constraints.len() { - let partner = sk.constraints.iter().enumerate().find_map(|(i, k)| { + let partner = sk.constraints.iter().find_map(|k| { let o = match k.kind { ConstraintKind::Coincident { p, q: r } if p == q && r != q => r, ConstraintKind::Coincident { p, q: r } if r == q && p != q => p, _ => return None, }; - let held_elsewhere = sk.constraints.iter().enumerate().any(|(j, k)| j != i && k.kind.refers_to_point(o)); + // What else holds the partner (a dimension measuring to the corner, #7507) holds + // the corner instead: the two are one place. Not where that would name the corner + // twice in one constraint (other than a repeat of this coincidence). + let twice = sk.constraints.iter().any(|other| { + let points = other.kind.indices().0; + !matches!(other.kind, ConstraintKind::Coincident { .. }) && points.contains(&o) && points.contains(&q) + }); let linked = (0..sk.curves.len()).any(|c| sk.is_linked_curve(c) && sk.curves.get(c).is_some_and(|x| x.kind.uses(o))); - (o != 0 && !held_elsewhere && !linked).then_some((i, o)) + (o != 0 && !twice && !linked).then_some(o) }); - let Some((i, o)) = partner else { return }; + let Some(o) = partner else { return }; for c in sk.curves.iter_mut() { c.kind.map_points(&|x| if x == o { q } else { x }); } - sk.constraints.remove(i); + for k in sk.constraints.iter_mut() { + k.kind.remap(&|x| Some(if x == o { q } else { x }), &Some); + } + // The coincidence, and any repeat of it, now joins the corner to itself. + sk.constraints.retain(|k| !matches!(k.kind, ConstraintKind::Coincident { p, q: r } if p == r)); sk.remove_points(&[o]); if o < q { q -= 1; @@ -1455,6 +1479,70 @@ fn refuse_offset_corner(sk: &Sketch, p: &Value, cmd: &str) -> Result<()> { } } +/// Two lines given as `a` and `b` that share no end are brought to where they meet, so they +/// make a corner to round or cut (#6912, #7759, #7985). Each line's end nearer that point moves +/// onto it: a line that stops short is extended, and one that runs past the other is cut there, +/// losing its shorter piece. Refused when the lines are parallel or meet too far away, and when +/// an end that would move is held by anything else (another curve, a constraint or dimension on +/// it, a fixed or driven position) or its line has a size of its own (a length, an equal or a +/// midpoint), which moving the end would change. +fn meet_lines(sk: &mut Sketch, p: &Value, cmd: &str) -> Result<()> { + if str_(p, "point").is_some() { + return Ok(()); + } + let line = |k: &str| str_(p, k).and_then(|i| sk.curve_index(i)); + let (Some(la), Some(lb)) = (line("a"), line("b")) else { return Ok(()) }; + let ends = |l: usize| match sk.curves.get(l).map(|c| &c.kind) { + Some(CurveKind::Line { a, b }) => Some((*a, *b)), + _ => None, + }; + let (Some((a0, a1)), Some((b0, b1))) = (ends(la), ends(lb)) else { return Ok(()) }; + if la == lb || [a0, a1].iter().any(|x| *x == b0 || *x == b1) { + return Ok(()); + } + let (Some(p0), Some(p1), Some(q0), Some(q1)) = (sk.point(a0), sk.point(a1), sk.point(b0), sk.point(b1)) else { return Ok(()) }; + let (da, db) = (p1 - p0, q1 - q0); + let den = da.cross(db); + if den.abs() <= 1e-9 * da.len() * db.len() { + return Err(bad(cmd, "the lines are parallel")); + } + // Where they meet, as a fraction along each line from its first end. + let (t, u) = ((q0 - p0).cross(db) / den, (q0 - p0).cross(da) / den); + let meet = p0 + da * t; + if !(meet.is_finite() && meet.len() < 1e7) { + return Err(bad(cmd, "the lines meet too far away")); + } + // The end on the shorter side of the meeting point moves (the nearer end, for a line that + // stops short of it). + let (na, nb) = (if t >= 0.5 { a1 } else { a0 }, if u >= 0.5 { b1 } else { b0 }); + for (l, e) in [(la, na), (lb, nb)] { + let id = sk.curves.get(l).map(|c| c.id.clone()).unwrap_or_default(); + let other_curve = sk.curves.iter().enumerate().any(|(i, c)| i != l && c.kind.uses(e)); + let positioned = sk.points.get(e).is_some_and(|q| q.fixed || q.exprs.is_some() || q.link.is_some()); + if e == 0 || other_curve || positioned || sk.is_linked_curve(l) || sk.constraints.iter().any(|k| k.kind.refers_to_point(e)) { + return Err(bad(cmd, format!("`{id}` doesn't reach the other line, and its end that would move there is held by other geometry"))); + } + let sized = sk.constraints.iter().any(|k| { + matches!(k.kind, ConstraintKind::Length { l: x, .. } | ConstraintKind::Midpoint { l: x, .. } if x == l) + || matches!(k.kind, ConstraintKind::Equal { a, b } if a == l || b == l) + }); + if sized { + return Err(bad(cmd, format!("`{id}` doesn't reach the other line, and its length is held: extending or cutting it would change that"))); + } + } + for e in [na, nb] { + if let Some(q) = sk.points.get_mut(e) { + q.pos = meet; + } + } + // One corner point: b's moved end becomes a's. + for c in sk.curves.iter_mut() { + c.kind.map_points(&|x| if x == nb { na } else { x }); + } + sk.remove_points(&[nb]); + Ok(()) +} + /// A corner of two lines: (line a, line b, corner point, far end of a, far end of b). fn corner(sk: &Sketch, p: &Value, cmd: &str) -> Result<(usize, usize, usize, usize, usize)> { let ends = |l: usize| match sk.curves.get(l).map(|c| &c.kind) { @@ -1634,34 +1722,40 @@ fn corner_arg(q: &Value) -> Value { } } +/// The corners a fillet or chamfer works on: several at once (`points`), each like a single +/// `point`, or the one corner `point` or `a`, `b` name. +fn corner_list(p: &Value, cmd: &str) -> Result> { + match p.get("points").and_then(Value::as_array) { + Some(a) if !a.is_empty() && a.len() <= 500 => Ok(a.iter().map(corner_arg).collect()), + Some(_) => Err(bad(cmd, "`points` must list 1…500 corners")), + None => Ok(vec![p.clone()]), + } +} + fn fillet(s: &mut Session, p: &Value) -> Result { let cmd = "sketch.fillet"; let r_expr = req_expr(cmd, p, "radius")?; - // Several corners at once (`points`), each like a single `point`. - let corners: Vec = match p.get("points").and_then(Value::as_array) { - Some(a) if !a.is_empty() && a.len() <= 500 => a.iter().map(corner_arg).collect(), - Some(_) => return Err(bad(cmd, "`points` must list 1…500 corners")), - None => vec![p.clone()], - }; + let corners = corner_list(p, cmd)?; let (out, info) = edit(s, p, cmd, false, |sk, doc| { let r = doc.eval(&r_expr, solidcraft_doc::expr::Kind::Length).map_err(|e| bad(cmd, format!("radius: {e}")))?; if !(r > 1e-9 && r < 1e8) { return Err(bad(cmd, "radius must be positive")); } let pname = doc.new_model_param(&r_expr, "mm"); - let (mut arcs, mut cons, mut kept) = (Vec::new(), Vec::new(), Vec::new()); + // The arcs by id: a fillet that takes a whole line removes it, which renumbers the curves. + let (mut arcs, mut cons, mut kept): (Vec, _, _) = (Vec::new(), Vec::new(), Vec::new()); for q in &corners { let (arc, mut c, k) = fillet_corner(sk, q, r, cmd)?; kept.push(k); // One radius for them all: the first carries the dimension, the rest are equal to it. - match arcs.first() { + match arcs.first().and_then(|first| sk.curve_index(first)) { None => c.push(sk.add_constraint(ConstraintKind::Radius { c: arc, value: r }, Some(pname.clone()))?), - Some(first) => c.push(add_c(sk, ConstraintKind::Equal { a: *first, b: arc })?), + Some(first) => c.push(add_c(sk, ConstraintKind::Equal { a: first, b: arc })?), } cons.extend(c); - arcs.push(arc); + arcs.push(sk.curves.get(arc).map(|c| c.id.clone()).unwrap_or_default()); } - Ok((ids_of(sk, &arcs), cons, pname, kept_json(kept))) + Ok((arcs, cons, pname, kept_json(kept))) })?; let (vc, moved) = out.3; Ok(json!({"curves": out.0, "constraints": out.1, "param": out.2, "virtual_corners": vc, "moved_to_virtual_corner": moved, "sketch": info})) @@ -1686,6 +1780,7 @@ fn fillet_corner(sk: &mut Sketch, p: &Value, r: f64, cmd: &str) -> Result<(usize refuse_infinite_corner(sk, p, cmd)?; refuse_offset_corner(sk, p, cmd)?; weld_corner(sk, p); + meet_lines(sk, p, cmd)?; let (la, lb, c, fa, fb) = match corner(sk, p, cmd) { Ok(x) => x, // Not two lines: a corner with an arc. @@ -1699,12 +1794,21 @@ fn fillet_corner(sk: &mut Sketch, p: &Value, r: f64, cmd: &str) -> Result<(usize return Err(bad(cmd, "the lines are parallel")); } let d = r / half.tan(); - if d >= pa.dist(pc) - 1e-9 || d >= pb.dist(pc) - 1e-9 { + let (len_a, len_b) = (pa.dist(pc), pb.dist(pc)); + // A radius that needs all of a line (the second corner of a side filleted with half its + // length, #2500): the line goes, and the arc meets what was at its far end. + let tol = 1e-7 * (1.0 + len_a.max(len_b)); + if d > len_a + tol || d > len_b + tol { return Err(bad(cmd, format!("the radius is too large for that corner (it needs {d:.3} mm of each line)"))); } + let (spent_a, spent_b) = (d >= len_a - tol, d >= len_b - tol); + // A spent line is cut just short of its far end, and the stub is removed below. + let short = |len: f64| len - (len * 1e-6).max(1e-6); + let far_ids = [fa, fb].map(|i| sk.points.get(i).map(|p| p.id.clone()).unwrap_or_default()); let bis = (u + v).normalized().ok_or_else(|| bad(cmd, "the lines are parallel"))?; let center = pc + bis * (r / half.sin()); - let (ta, tb, _, kept) = cut_corner(sk, la, lb, c, fa, fb, d, d, cmd)?; + let (da, db) = (if spent_a { short(len_a) } else { d }, if spent_b { short(len_b) } else { d }); + let (ta, tb, _, kept) = cut_corner(sk, la, lb, c, fa, fb, da, db, cmd)?; let (qa, qb) = (sk.point(ta).unwrap_or_default(), sk.point(tb).unwrap_or_default()); let ccw = (qa - center).cross(qb - center) > 0.0; let arc = if ccw { @@ -1712,7 +1816,35 @@ fn fillet_corner(sk: &mut Sketch, p: &Value, r: f64, cmd: &str) -> Result<(usize } else { sk.add_arc(center, qb, qa, [None, Some(tb), Some(ta)], None)? }; - let cons = vec![add_c(sk, ConstraintKind::Tangent { a: la, b: arc })?, add_c(sk, ConstraintKind::Tangent { a: lb, b: arc })?]; + if !spent_a && !spent_b { + let cons = vec![add_c(sk, ConstraintKind::Tangent { a: la, b: arc })?, add_c(sk, ConstraintKind::Tangent { a: lb, b: arc })?]; + return Ok((arc, cons, kept)); + } + let id = |sk: &Sketch, i: usize| sk.curves.get(i).map(|c| c.id.clone()).unwrap_or_default(); + let arc_id = id(sk, arc); + let mut cons = Vec::new(); + let mut stubs = Vec::new(); + for ((line, t, spent), far_id) in [(la, ta, spent_a), (lb, tb, spent_b)].into_iter().zip(&far_ids) { + if !spent { + cons.push(add_c(sk, ConstraintKind::Tangent { a: line, b: arc })?); + continue; + } + let Some(far) = sk.point_index(far_id) else { return Err(bad(cmd, "the line's far end is gone")) }; + // The arc ends where the line did, tangent to the curve that goes on from there. + if let Some(cu) = sk.curves.get_mut(arc) { + cu.kind.map_points(&|i| if i == t { far } else { i }); + } + let next = (0..sk.curves.len()).find(|&i| { + i != line && i != arc && sk.curves.get(i).is_some_and(|cu| !cu.construction && cu.kind.ends().is_some_and(|(a, b)| a == far || b == far)) + }); + if let Some(n) = next { + cons.push(add_c(sk, ConstraintKind::Tangent { a: n, b: arc })?); + } + stubs.push(id(sk, line)); + } + let stubs: Vec = stubs.iter().filter_map(|s| sk.curve_index(s)).collect(); + sk.remove_curves(&stubs); + let arc = sk.curve_index(&arc_id).ok_or_else(|| bad(cmd, "the fillet arc is gone"))?; Ok((arc, cons, kept)) } @@ -1811,31 +1943,63 @@ fn round_corner(sk: &mut Sketch, p: &Value, r: f64, cmd: &str) -> Result Result<(f64, f64)>) -> Result { +fn chamfer(s: &mut Session, p: &Value, cmd: &str, dists: impl Fn(f64) -> Result<(f64, f64)>) -> Result { + let corners = corner_list(p, cmd)?; let (out, info) = edit(s, p, cmd, false, |sk, _| { - refuse_spatial_corner(sk, p, cmd)?; - refuse_infinite_corner(sk, p, cmd)?; - refuse_offset_corner(sk, p, cmd)?; - weld_corner(sk, p); - let (la, lb, c, fa, fb) = corner(sk, p, cmd)?; - let (pc, pa, pb) = (sk.point(c).unwrap_or_default(), sk.point(fa).unwrap_or_default(), sk.point(fb).unwrap_or_default()); - let (u, v) = ((pa - pc).normalized().unwrap_or(Vec2::X), (pb - pc).normalized().unwrap_or(Vec2::Y)); - let corner_angle = u.cross(v).atan2(u.dot(v)).abs(); - // Lines in line with each other, or lying on each other, have no corner to cut, as a - // fillet refuses them: on two lines lying on each other the chamfer was a line of no - // length between their cut-back ends. - if !(2e-6..=std::f64::consts::PI - 2e-9).contains(&corner_angle) { - return Err(bad(cmd, "the lines are parallel")); + // Every corner is cut alike, in one edit: a corner that can't be cut refuses them all. + // A corner named twice (its point, then its two lines, say) would be cut again where + // the first cut left a new corner, so it is refused. Two lines that share no end are + // brought to where they meet first, so that corner is found by its point too. + let mut seen = std::collections::BTreeSet::new(); + for q in &corners { + prepare_corner(sk, q, cmd)?; + if let Ok((la, lb, c, _, _)) = corner(sk, q, cmd) + && !seen.insert((c, la.min(lb), la.max(lb))) + { + return Err(bad(cmd, "a corner is listed twice")); + } } - let (da, db) = dists(corner_angle)?; - let (ta, tb, _, kept) = cut_corner(sk, la, lb, c, fa, fb, da, db, cmd)?; - let l = sk.add_line_pts(ta, tb, None)?; - Ok((ids_of(sk, &[l]), kept_json(vec![kept]))) + let (mut lines, mut kept) = (Vec::new(), Vec::new()); + for q in &corners { + let (l, k) = chamfer_corner(sk, q, cmd, &dists)?; + lines.push(l); + kept.push(k); + } + Ok((ids_of(sk, &lines), kept_json(kept))) })?; let (curves, (vc, moved)) = out; Ok(json!({"curves": curves, "virtual_corners": vc, "moved_to_virtual_corner": moved, "sketch": info})) } +/// Cut one corner (`point` or `a`, `b`) of two lines with a straight line, `dists` giving how +/// far back along each line from the corner's angle: the new line and what became of the corner. +fn chamfer_corner(sk: &mut Sketch, p: &Value, cmd: &str, dists: &impl Fn(f64) -> Result<(f64, f64)>) -> Result<(usize, CornerKept)> { + prepare_corner(sk, p, cmd)?; + let (la, lb, c, fa, fb) = corner(sk, p, cmd)?; + let (pc, pa, pb) = (sk.point(c).unwrap_or_default(), sk.point(fa).unwrap_or_default(), sk.point(fb).unwrap_or_default()); + let (u, v) = ((pa - pc).normalized().unwrap_or(Vec2::X), (pb - pc).normalized().unwrap_or(Vec2::Y)); + let corner_angle = u.cross(v).atan2(u.dot(v)).abs(); + // Lines in line with each other, or lying on each other, have no corner to cut, as a + // fillet refuses them: on two lines lying on each other the chamfer was a line of no + // length between their cut-back ends. + if !(2e-6..=std::f64::consts::PI - 2e-9).contains(&corner_angle) { + return Err(bad(cmd, "the lines are parallel")); + } + let (da, db) = dists(corner_angle)?; + let (ta, tb, _, kept) = cut_corner(sk, la, lb, c, fa, fb, da, db, cmd)?; + Ok((sk.add_line_pts(ta, tb, None)?, kept)) +} + +/// The checks and joins before a chamfer cuts a corner. Running them again on a corner already +/// prepared changes nothing. +fn prepare_corner(sk: &mut Sketch, p: &Value, cmd: &str) -> Result<()> { + refuse_spatial_corner(sk, p, cmd)?; + refuse_infinite_corner(sk, p, cmd)?; + refuse_offset_corner(sk, p, cmd)?; + weld_corner(sk, p); + meet_lines(sk, p, cmd) +} + fn chamfer_equal(s: &mut Session, p: &Value) -> Result { let cmd = "sketch.chamfer.equal_distance"; let d = num(p, "distance").ok_or_else(|| bad(cmd, "`distance` must be a number"))?; @@ -1866,13 +2030,46 @@ fn chamfer_dist_angle(s: &mut Session, p: &Value) -> Result { // --------------------------------------------------------------------------------------------- // Offset +/// Ends this close are one corner. Profile finding joins ends at the same distance, so a loop +/// the sketch already treats as one profile offsets as one chain. +const ENDPOINT_JOIN: f64 = 1e-4; + /// Curves of the connected chain through `start` (joined where exactly two curves meet), in /// order, with whether each is walked from its stored start (`a`) to its end (`b`). +/// +/// A corner counts when the curves share the point, when a coincident constraint makes the two +/// ends one point, or when the ends simply lie on each other. The offset then trims a corner +/// that runs past the crossing and extends one that stops short, so the new curves intersect. fn chain(sk: &Sketch, start: usize, pool: &[usize]) -> (Vec<(usize, bool)>, bool) { - let ends = |c: usize| sk.curves.get(c).and_then(|c| c.kind.ends()); + // Ends joined by a coincident constraint are one joint, as well as a shared point: lines drawn + // one by one meet at their own points, held together by constraints, and must chain the same + // (else each is offset on its own, to the left of the way it was drawn). + let groups = coincident_points(sk); + let root = |i: usize| groups.get(i).copied().unwrap_or(i); + let endpoints: Vec<_> = sk.curves.iter().map(|c| c.kind.ends().map(|(a, b)| (root(a), root(b)))).collect(); + let ends = |c: usize| endpoints.get(c).copied().flatten(); let Some((s0, s1)) = ends(start) else { return (vec![(start, true)], true) }; + let mut parent: Vec = (0..sk.points.len()).collect(); + for constraint in &sk.constraints { + if let ConstraintKind::Coincident { p, q } = constraint.kind { + union_points(&mut parent, p, q); + } + } + let group: Vec = (0..parent.len()).map(|i| find_point(&mut parent, i)).collect(); + let same = |a: usize, b: usize| -> bool { + if a == b { + return true; + } + if group.get(a).zip(group.get(b)).is_some_and(|(x, y)| x == y) { + return true; + } + match (sk.point(a), sk.point(b)) { + (Some(p), Some(q)) if p.is_finite() && q.is_finite() => p.dist(q) <= ENDPOINT_JOIN, + _ => false, + } + }; let next = |from: usize, at: usize, used: &[usize]| -> Option { - let users: Vec = pool.iter().copied().filter(|c| ends(*c).is_some_and(|(a, b)| a == at || b == at)).collect(); + let users: Vec = pool.iter().copied().filter(|c| ends(*c).is_some_and(|(a, b)| same(a, at) || same(b, at))).collect(); if users.len() != 2 { return None; } @@ -1884,7 +2081,7 @@ fn chain(sk: &Sketch, start: usize, pool: &[usize]) -> (Vec<(usize, bool)>, bool let mut closed = false; while let Some(n) = next(cur, at, &used) { let Some((a, b)) = ends(n) else { break }; - let forward = a == at; + let forward = same(a, at); fwd.push((n, forward)); used.push(n); cur = n; @@ -1893,7 +2090,7 @@ fn chain(sk: &Sketch, start: usize, pool: &[usize]) -> (Vec<(usize, bool)>, bool break; } } - if at == s0 && fwd.len() > 1 { + if fwd.len() > 1 && same(at, s0) { closed = true; } else { // Walk backwards from the start. @@ -1901,7 +2098,7 @@ fn chain(sk: &Sketch, start: usize, pool: &[usize]) -> (Vec<(usize, bool)>, bool let mut back = Vec::new(); while let Some(n) = next(cur, at, &used) { let Some((a, b)) = ends(n) else { break }; - let forward = b == at; + let forward = same(b, at); back.push((n, forward)); used.push(n); cur = n; @@ -1917,6 +2114,33 @@ fn chain(sk: &Sketch, start: usize, pool: &[usize]) -> (Vec<(usize, bool)>, bool (fwd, closed) } +fn find_point(parent: &mut [usize], mut i: usize) -> usize { + let mut top = i; + while parent.get(top).copied().unwrap_or(top) != top { + top = parent[top]; + } + while i != top { + let next = parent.get(i).copied().unwrap_or(top); + if let Some(slot) = parent.get_mut(i) { + *slot = top; + } + i = next; + } + top +} + +fn union_points(parent: &mut [usize], a: usize, b: usize) { + if a >= parent.len() || b >= parent.len() { + return; + } + let (a, b) = (find_point(parent, a), find_point(parent, b)); + if a != b + && let Some(slot) = parent.get_mut(b) + { + *slot = a; + } +} + /// An offset piece: shape walked in chain direction, offset by `d` to the left. #[derive(Clone)] enum Piece { @@ -2853,20 +3077,52 @@ fn release_origin_holds(sk: &mut Sketch, pts: &[usize]) { ); } +/// Separate drawn wires from planar entities. Their fit points use either the owner's local +/// frame or the legacy world frame, so each must turn about the sketch plane in that frame. +fn drawn_copy_selection(s: &Session, p: &Value, ids: Vec) -> Result<(Vec, Vec<(Wire, Plane)>)> { + let cmd = "sketch.move"; + let id = sketch_store::target(s, p, cmd)?; + let sk = sketch_store::read(&s.doc, id)?; + let mut planar = Vec::new(); + let mut drawn: Vec<(Wire, Plane)> = Vec::new(); + for entity in ids { + let Some(wire) = sk.wires.iter().find(|w| w.id == entity) else { + planar.push(entity); + continue; + }; + if wire.link.is_some() || wire.fit.is_empty() { + return Err(bad(cmd, "Copy supports drawn 3D curves; reference wires cannot be copied")); + } + if drawn.iter().any(|(w, _)| w.id == entity) { + continue; + } + let plane = match wire.space { + WireSpace::Component => s.model.state().sketch(id).map(|sk| sk.plane), + WireSpace::World => s.world_state().sketch(id).map(|sk| sk.plane), + } + .ok_or_else(|| bad(cmd, "sketch plane is unavailable"))?; + drawn.push((wire.clone(), plane)); + } + Ok((planar, drawn)) +} + fn move_copy(s: &mut Session, p: &Value) -> Result { let cmd = "sketch.move"; let t = if p.get("translate").is_some() { req_vec2(cmd, p, "translate")? } else { Vec2::ZERO }; let ang = if p.get("angle").is_some() { num(p, "angle").ok_or_else(|| bad(cmd, "`angle` must be finite"))? } else { 0.0 }.to_radians(); let copy = bool_(p, "copy").unwrap_or(false); let rotate_constraints = bool_(p, "rotate_constraints").unwrap_or(true); + let selection = string_list(cmd, p, "entities")?; + let (planar, drawn) = if copy { drawn_copy_selection(s, p, selection)? } else { (selection, Vec::new()) }; let (out, info) = edit(s, p, cmd, false, |sk, doc| { - let (mut cs, mut ps) = entities(sk, p, cmd)?; + let (mut cs, mut ps) = + if planar.is_empty() && !drawn.is_empty() { (Vec::new(), Vec::new()) } else { entities(sk, &json!({"entities": planar}), 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(q) + t; let mut links = Vec::new(); - for entity in string_list(cmd, p, "entities")? { - if let Some(link) = super::sketch_create::text_link(sk, &entity) + for entity in &planar { + if let Some(link) = super::sketch_create::text_link(sk, entity) && !links.iter().any(|old| old == link) { links.push(link.to_owned()); @@ -2881,7 +3137,12 @@ fn move_copy(s: &mut Session, p: &Value) -> Result { if copy { let (mut m, _) = copy_through(sk, &cs, &ps, &f, false, &|_| false, 1.0)?; m.extend(text_curves); - return Ok(ids_of(sk, &m)); + let mut ids = ids_of(sk, &m); + for (wire, plane) in &drawn { + let fit: Vec<_> = wire.fit.iter().map(|&q| plane.to_world(f(plane.to_local(q))) + plane.normal() * plane.height(q)).collect(); + ids.push(if wire.arc.is_some() { sk.add_drawn_arc_in(&fit, wire.space)? } else { sk.add_drawn_wire_in(&fit, wire.space)? }); + } + return Ok(ids); } transform_in_place(sk, &cs, &ps, &f, 1.0, cmd)?; if rotate_constraints { diff --git a/crates/engine/src/cmd/sketch_modify_tests.rs b/crates/engine/src/cmd/sketch_modify_tests.rs index 20ef7081e2..32f30349ee 100644 --- a/crates/engine/src/cmd/sketch_modify_tests.rs +++ b/crates/engine/src/cmd/sketch_modify_tests.rs @@ -40,6 +40,66 @@ fn close(a: f64, b: f64) -> bool { (a - b).abs() < 1e-6 } +fn assert_closed_offset(sk: &solidcraft_sketch::Sketch, curves: &[String]) { + assert!(!curves.is_empty(), "an offset boundary must contain curves"); + let ends: Vec<_> = curves.iter().map(|id| sk.curves[sk.curve_index(id).unwrap()].kind.ends().unwrap()).collect(); + let mut adjacency = std::collections::BTreeMap::>::new(); + for (edge, &(a, b)) in ends.iter().enumerate() { + assert_ne!(a, b, "offset edge must have distinct endpoints"); + adjacency.entry(a).or_default().push(edge); + adjacency.entry(b).or_default().push(edge); + } + for (point, edges) in &adjacency { + assert_eq!(edges.len(), 2, "offset corner {point} must be shared by two curves"); + } + let mut visited = std::collections::BTreeSet::new(); + let mut pending = vec![0]; + while let Some(edge) = pending.pop() { + if visited.insert(edge) { + let (a, b) = ends[edge]; + pending.extend(&adjacency[&a]); + pending.extend(&adjacency[&b]); + } + } + assert_eq!(visited.len(), ends.len(), "all offset curves must belong to one connected loop"); +} + +#[test] +#[should_panic(expected = "all offset curves must belong to one connected loop")] +fn closed_offset_assertion_rejects_disconnected_loops() { + let mut sk = solidcraft_sketch::Sketch::new(); + let mut curves = Vec::new(); + for x in [0.0, 20.0] { + let a = sk.add_point(Vec2::new(x, 0.0), None).unwrap(); + let b = sk.add_point(Vec2::new(x + 10.0, 0.0), None).unwrap(); + let c = sk.add_point(Vec2::new(x, 10.0), None).unwrap(); + for (p, q) in [(a, b), (b, c), (c, a)] { + let edge = sk.add_line_pts(p, q, None).unwrap(); + curves.push(sk.curves[edge].id.clone()); + } + } + assert_closed_offset(&sk, &curves); +} + +#[test] +fn offset_chain_resolves_transitive_coincidence_and_stops_at_branches() { + let mut sk = solidcraft_sketch::Sketch::new(); + for xy in [[0., 0.], [10., 0.], [10., 0.], [20., 0.], [10., 0.], [10., 10.], [10., 0.]] { + sk.add_point(Vec2::new(xy[0], xy[1]), None).unwrap(); + } + let a = sk.add_line_pts(0, 1, None).unwrap(); + let b = sk.add_line_pts(2, 3, None).unwrap(); + let c = sk.add_line_pts(4, 5, None).unwrap(); + // Equal coordinates do not connect curves without shared points or constraints. + assert_eq!(super::chain(&sk, a, &[a, b]), (vec![(a, true)], false)); + sk.add_constraint(ConstraintKind::Coincident { p: 1, q: 6 }, None).unwrap(); + sk.add_constraint(ConstraintKind::Coincident { p: 2, q: 6 }, None).unwrap(); + assert_eq!(super::chain(&sk, a, &[a, b]), (vec![(a, true), (b, true)], false)); + sk.add_constraint(ConstraintKind::Coincident { p: 4, q: 6 }, None).unwrap(); + assert_eq!(super::chain(&sk, a, &[a, b, c]), (vec![(a, true)], false)); + assert_eq!(super::chain(&sk, a, &[a, b]), (vec![(a, true), (b, true)], false)); +} + #[test] fn explicit_freeform_break_preserves_exact_geometry_and_undo() { for (command, params) in [ @@ -578,6 +638,71 @@ fn exact_extend_takes_one_of_two_stops_at_the_same_distance() { assert!(end.dist(Vec2::new(10.0, 0.0)) < 1e-6, "{end:?}"); } +#[test] +fn extend_does_not_treat_a_line_end_inside_a_circle_as_lying_on_it() { + // #9731: the "lies on a circle" test was true for every end *inside* a circle, so a large + // construction circle around the drawing made Extend treat a free end as held and lay a + // loose new line instead of lengthening the line. Only an end on the circle is held. + let mut s = new_sketch(); + let l1 = ids(&run(&mut s, "sketch.line", json!({"points":[[0,5],[10,5]],"connect":false,"infer":false}))["curves"])[0].clone(); + let l2 = ids(&run(&mut s, "sketch.line", json!({"points":[[20,-10],[20,20]],"connect":false,"infer":false}))["curves"])[0].clone(); + run(&mut s, "sketch.circle.center", json!({"center":[5,5],"radius":60})); + run(&mut s, "sketch.extend", json!({"curve": l1, "at": [9,5]})); + let sk = sketch(&s); + assert_eq!(sk.curves.len(), 3, "the circle around the drawing must not add a line"); + let end = sk.resolve_point(&format!("{l1}.end")).expect("l1's end"); + assert!(sk.point(end).is_some_and(|p| p.dist(Vec2::new(20.0, 5.0)) < 1e-6), "l1 reaches l2: {:?}", sk.point(end)); + let l2i = sk.curve_index(&l2).expect("l2"); + assert!( + sk.constraints.iter().any(|c| matches!(c.kind, ConstraintKind::PointOnCurve { p, c } if p == end && c == l2i)), + "the extended end is held on l2" + ); + + // The same end deep inside a far larger circle behaves the same way. + let mut s = new_sketch(); + let l1 = ids(&run(&mut s, "sketch.line", json!({"points":[[0,5],[10,5]],"connect":false,"infer":false}))["curves"])[0].clone(); + run(&mut s, "sketch.line", json!({"points":[[20,-10],[20,20]],"connect":false,"infer":false})); + run(&mut s, "sketch.circle.center", json!({"center":[100,100],"radius":200})); + run(&mut s, "sketch.extend", json!({"curve": l1, "at": [9,5]})); + let sk = sketch(&s); + assert_eq!(sk.curves.len(), 3, "an end inside a circle is not held by it"); + let end = sk.resolve_point(&format!("{l1}.end")).expect("l1's end"); + assert!(sk.point(end).is_some_and(|p| p.dist(Vec2::new(20.0, 5.0)) < 1e-6), "{:?}", sk.point(end)); + + // An end that lies ON the circle is still held: Extend adds a new line from it. + let mut s = new_sketch(); + let l1 = ids(&run(&mut s, "sketch.line", json!({"points":[[0,5],[10,5]],"connect":false,"infer":false}))["curves"])[0].clone(); + run(&mut s, "sketch.line", json!({"points":[[20,-10],[20,20]],"connect":false,"infer":false})); + run(&mut s, "sketch.circle.center", json!({"center":[5,5],"radius":5})); + run(&mut s, "sketch.extend", json!({"curve": l1, "at": [9,5]})); + let sk = sketch(&s); + assert_eq!(sk.curves.len(), 4, "an end on the circle stays put and gains a new line"); + let end = sk.resolve_point(&format!("{l1}.end")).expect("l1's end"); + assert!(sk.point(end).is_some_and(|p| p.dist(Vec2::new(10.0, 5.0)) < 1e-6), "l1 stays put: {:?}", sk.point(end)); +} + +#[test] +fn extend_from_a_held_end_is_pinned_to_the_curve_it_stops_on() { + // The end being extended carries geometry (another line meets it there), so the extension is + // a new line. It stops in the middle of a third curve: the new end point is held on that + // curve, as the free-end path holds its end, instead of lying loose over it. + let mut s = new_sketch(); + let both = ids(&run(&mut s, "sketch.line", json!({"points":[[0,0],[10,0],[10,10]],"connect":false,"infer":false}))["curves"]); + let l1 = both[0].clone(); + let wall = ids(&run(&mut s, "sketch.line", json!({"points":[[20,-10],[20,10]],"connect":false,"infer":false}))["curves"])[0].clone(); + run(&mut s, "sketch.extend", json!({"curve": l1, "at": [10,0]})); + let sk = sketch(&s); + assert_eq!(sk.curves.len(), 4, "the held end stays and a new line is added"); + let new_line = sk.curves.last().expect("the new line"); + let CurveKind::Line { b, .. } = new_line.kind else { panic!("the new curve is a line: {new_line:?}") }; + assert!(sk.point(b).is_some_and(|p| p.dist(Vec2::new(20.0, 0.0)) < 1e-6), "the new line stops on the wall: {:?}", sk.point(b)); + let walli = sk.curve_index(&wall).expect("the wall"); + assert!( + sk.constraints.iter().any(|c| matches!(c.kind, ConstraintKind::PointOnCurve { p, c } if p == b && c == walli)), + "the new end point is held on the curve it stops on" + ); +} + #[test] fn fillet_keeps_expression_corner_when_parameter_changes() { let mut s = new_sketch(); @@ -643,6 +768,133 @@ fn fillet_and_chamfers() { assert!(s.execute("sketch.fillet", &json!({"a": l[0], "b": l[2], "radius": 1})).is_err(), "no shared corner"); } +/// #2500, #5951, #7805: the second fillet of a side with a radius of half its length takes all +/// that is left of the line, so the line goes and the two arcs meet: a 20 × 10 rectangle with +/// r = 5 becomes a slot, a 10 × 10 square a circle. +#[test] +fn fillets_of_half_a_side_meet_and_the_side_goes() { + for (w, h, area) in [(20.0, 10.0, 10.0 * 10.0 + std::f64::consts::PI * 25.0), (10.0, 10.0, std::f64::consts::PI * 25.0)] { + let mut s = new_sketch(); + let l = ids(&run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [w, h]}))["curves"]); + let mut last = Value::Null; + for k in 0..4 { + last = run(&mut s, "sketch.fillet", json!({"a": l[k], "b": l[(k + 1) % 4], "radius": 5})); + } + assert_eq!(last["sketch"]["solved"], true, "{last}"); + let sk = sketch(&s); + let lines = sk.curves.iter().filter(|c| !c.construction && matches!(c.kind, CurveKind::Line { .. })).count(); + let arcs = sk.curves.iter().filter(|c| matches!(c.kind, CurveKind::Arc { .. })).count(); + assert_eq!((lines, arcs), (if w > h { 2 } else { 0 }, 4), "{w} x {h}"); + let a = profiles(&s); + assert_eq!(a.len(), 1, "{a:?}"); + assert!(close(a[0], area), "{w} x {h}: {a:?}"); + } + // All four corners in one call: the arcs keep one radius dimension and equal constraints + // although the short sides go in between. + let mut s = new_sketch(); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 10]})); + let f = run(&mut s, "sketch.fillet", json!({"points": ["l1.start", "l1.end", "l2.end", "l3.end"], "radius": 5})); + assert_eq!(ids(&f["curves"]).len(), 4, "{f}"); + assert_eq!(f["sketch"]["solved"], true, "{f}"); + let a = profiles(&s); + assert!(a.len() == 1 && close(a[0], 100.0 + std::f64::consts::PI * 25.0), "{a:?}"); + let equal = sketch(&s).constraints.iter().filter(|c| matches!(c.kind, ConstraintKind::Equal { .. })).count(); + assert!(equal >= 3, "the arcs stay equal to the first: {equal}"); + // A radius past the line's length is still refused. + let mut s = new_sketch(); + let l = ids(&run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 10]}))["curves"]); + run(&mut s, "sketch.fillet", json!({"a": l[0], "b": l[1], "radius": 5})); + assert!(s.execute("sketch.fillet", &json!({"a": l[1], "b": l[2], "radius": 5.1})).is_err()); +} + +/// A rectangle drawn as four separate lines (each its own points, the corners held by +/// coincident constraints) offsets as one closed shape, every side the same way. Its lines used +/// to be offset one by one, each to the left of the way it was drawn: both horizontals moved up +/// and the corners came apart. +#[test] +fn offset_of_lines_drawn_one_by_one_stays_a_closed_shape() { + let mut s = new_sketch(); + for pts in [[[0, 0], [20, 0]], [[20, 0], [20, 10]], [[0, 10], [20, 10]], [[0, 0], [0, 10]]] { + run(&mut s, "sketch.line", json!({"points": pts})); + } + let r = run(&mut s, "sketch.offset", json!({"curves": ["l1", "l2", "l3", "l4"], "distance": 2})); + let made: Vec = r["curves"].as_array().unwrap().iter().map(|c| c.as_str().unwrap().to_string()).collect(); + assert_eq!(made.len(), 4, "{r}"); + let sk = sketch(&s); + let ends: Vec<(Vec2, Vec2)> = made + .iter() + .map(|id| match sk.curves[sk.curve_index(id).unwrap()].kind { + CurveKind::Line { a, b } => (sk.points[a].pos, sk.points[b].pos), + _ => panic!("{id} is not a line"), + }) + .collect(); + // Every side 2 in: the bottom up, the top down, the sides in. + let mut ys: Vec = ends.iter().filter(|e| (e.0.y - e.1.y).abs() < 1e-9).map(|e| e.0.y).collect(); + let mut xs: Vec = ends.iter().filter(|e| (e.0.x - e.1.x).abs() < 1e-9).map(|e| e.0.x).collect(); + ys.sort_by(f64::total_cmp); + xs.sort_by(f64::total_cmp); + assert!(ys.len() == 2 && (ys[0] - 2.0).abs() < 1e-9 && (ys[1] - 8.0).abs() < 1e-9, "{ys:?}"); + assert!(xs.len() == 2 && (xs[0] - 2.0).abs() < 1e-9 && (xs[1] - 18.0).abs() < 1e-9, "{xs:?}"); + assert_closed_offset(&sk, &made); +} + +#[test] +fn offset_of_trimmed_circle_and_fillet_chains_coincident_ends() { + let mut s = new_sketch(); + run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 10})); + run(&mut s, "sketch.line", json!({"points": [[-20, 0], [20, 0]]})); + run(&mut s, "sketch.trim", json!({"curve": "c1", "at": [0, -10]})); + run(&mut s, "sketch.trim", json!({"curve": "l1", "at": [-15, 0]})); + run(&mut s, "sketch.trim", json!({"curve": "l1", "at": [15, 0]})); + // A point on the line alone is a different case: #5019 explicitly joins these ends + // with Coincident, while the fillet makes its own two shared ends. + run(&mut s, "sketch.constraint.coincident", json!({"a": "c1.end", "b": "l1.start"})); + let fillet = run(&mut s, "sketch.fillet", json!({"a": "c1", "b": "l1", "radius": 1})); + let arc = ids(&fillet["curves"])[0].clone(); + assert_eq!(profiles(&s).len(), 1); + let r = run(&mut s, "sketch.offset", json!({"curves": ["c1", "l1", arc], "distance": -0.5})); + assert_eq!(r["sides"].as_array().unwrap().len(), 1, "{r}"); + let made = ids(&r["curves"]); + assert_eq!(made.len(), 3, "{r}"); + let sk = sketch(&s); + assert_closed_offset(&sk, &made); + let mut radii: Vec<_> = made + .iter() + .filter_map(|id| match sk.curves[sk.curve_index(id).unwrap()].kind { + CurveKind::Arc { c, a, .. } => Some(sk.points[c].pos.dist(sk.points[a].pos)), + _ => None, + }) + .collect(); + radii.sort_by(f64::total_cmp); + assert!(radii.len() == 2 && close(radii[0], 1.5) && close(radii[1], 10.5), "{radii:?}"); +} + +#[test] +fn offset_of_separately_drawn_fillet_and_chamfer_stays_closed() { + let mut s = new_sketch(); + for points in [[[0, 0], [20, 0]], [[20, 0], [20, 10]], [[0, 10], [20, 10]], [[0, 0], [0, 10]]] { + run(&mut s, "sketch.line", json!({"points": points})); + } + run(&mut s, "sketch.fillet", json!({"a": "l1", "b": "l2", "radius": 2})); + run(&mut s, "sketch.chamfer.equal_distance", json!({"a": "l3", "b": "l4", "distance": 1})); + let sources: Vec<_> = sketch(&s).curves.iter().map(|c| c.id.clone()).collect(); + assert_eq!(profiles(&s).len(), 1); + let r = run(&mut s, "sketch.offset", json!({"curves": sources, "distance": 1})); + assert_eq!(r["sides"].as_array().unwrap().len(), 1, "{r}"); + let made = ids(&r["curves"]); + assert_eq!(made.len(), 6, "{r}"); + let sk = sketch(&s); + assert_closed_offset(&sk, &made); + let radii: Vec<_> = made + .iter() + .filter_map(|id| match sk.curves[sk.curve_index(id).unwrap()].kind { + CurveKind::Arc { c, a, .. } => Some(sk.points[c].pos.dist(sk.points[a].pos)), + _ => None, + }) + .collect(); + assert!(radii.len() == 1 && close(radii[0], 1.0), "{radii:?}"); +} + #[test] fn offset_chain_mirror_patterns_move_scale() { let mut s = new_sketch(); @@ -1085,6 +1337,53 @@ fn centre_circle_through_a_point_keeps_it_on_the_circle() { assert!(!sketch(&s).constraints.iter().any(|k| matches!(k.kind, ConstraintKind::PointOnCurve { c, .. } if c == ci))); } +/// #9858: a 3-point circle drawn on snapped clicks keeps them. Drawn through a line's start, +/// its midpoint and another line's end, the circle stays on those points when the line is +/// dimensioned. +#[test] +fn three_point_circle_keeps_snapped_points() { + let mut s = new_sketch(); + let l = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [40, 0]]}))["curves"])[0].clone(); + let v = ids(&run(&mut s, "sketch.line", json!({"points": [[20, 5], [20, 30]]}))["curves"])[0].clone(); + let r = run(&mut s, "sketch.circle.three_point", json!({"p0": format!("{l}.start"), "p1": format!("mid:{l}"), "p2": format!("{v}.end")})); + let c = ids(&r["curves"])[0].clone(); + assert_eq!(ids(&r["constraints"]).len(), 3, "{r}"); + let sk = sketch(&s); + let ci = sk.curve_index(&c).unwrap(); + let on_circle = + |sk: &solidcraft_sketch::Sketch, p: usize, c: usize| sk.constraints.iter().any(|k| k.kind == ConstraintKind::PointOnCurve { p, c }); + let (start, tip) = (sk.resolve_point(&format!("{l}.start")).unwrap(), sk.resolve_point(&format!("{v}.end")).unwrap()); + // The midpoint snap holds its point at the line's midpoint, and the circle keeps it too. + let mid = sk + .constraints + .iter() + .find_map(|k| match k.kind { + ConstraintKind::Midpoint { p, .. } => Some(p), + _ => None, + }) + .expect("midpoint snap point"); + for p in [start, mid, tip] { + assert!(on_circle(&sk, p, ci), "{:?} is not on the circle: {:?}", sk.points[p].id, sk.constraints); + } + // Seeded as the circumcircle of the three clicks. + let (o, rad) = (sk.center(ci).unwrap(), sk.radius(ci).unwrap()); + for p in [start, mid, tip] { + assert!(close(o.dist(sk.points[p].pos), rad), "the circle does not pass its click: {o:?} {rad}"); + } + // Dimensioning the line: the circle stays on the start, midpoint and end. + run(&mut s, "sketch.dimension", json!({"entities": [l], "value": 60})); + let sk = sketch(&s); + let (o, rad) = (sk.center(ci).unwrap(), sk.radius(ci).unwrap()); + for p in [start, mid, tip] { + assert!(close(o.dist(sk.points[p].pos), rad), "the circle left a snapped point: {:?}", sk.points); + } + // Plain positions: nothing to keep. + let r = run(&mut s, "sketch.circle.three_point", json!({"p0": [100, 0], "p1": [110, 0], "p2": [105, 8]})); + let ci = sketch(&s).curve_index(&ids(&r["curves"])[0]).unwrap(); + assert!(ids(&r["constraints"]).is_empty()); + assert!(!sketch(&s).constraints.iter().any(|k| matches!(k.kind, ConstraintKind::PointOnCurve { c, .. } if c == ci))); +} + #[test] fn ellipse_splines_conic() { let pi = std::f64::consts::PI; @@ -1603,6 +1902,45 @@ fn fillet_a_line_and_an_arc_drawn_to_meet_at_a_point() { } } +/// #7507: a line drawn on from another line's end by coordinates meets it through a Coincident +/// constraint, and a dimension measures to the second line's own end at that corner. The corner +/// is still rounded, by point and by naming the two lines, and the dimension keeps measuring to +/// the sharp corner. +#[test] +fn fillet_a_coincident_corner_that_a_dimension_measures_to() { + for how in [json!({"point": "l1.end"}), json!({"a": "l1", "b": "l2"})] { + let mut s = new_sketch(); + run(&mut s, "sketch.line", json!({"points": [[0, 0], [40, 0]]})); + run(&mut s, "sketch.line", json!({"points": [[40, 0], [100, -30]]})); + run(&mut s, "sketch.dimension", json!({"entities": ["origin", "l2.start"], "type": "horizontal", "value": "40"})); + let mut p = how.clone(); + p["radius"] = json!(5); + let f = run(&mut s, "sketch.fillet", p); + let made = ids(&f["curves"]); + assert_eq!(made.len(), 1, "{how}: {f}"); + let sk = sketch(&s); + let ai = sk.curve_index(&made[0]).unwrap(); + assert!((sk.radius(ai).unwrap() - 5.0).abs() < 1e-7, "{how}"); + // Tangent to both lines: 5 below the first and 5 from the second's line. + let c = sk.center(ai).unwrap(); + let along = Vec2::new(60.0, -30.0).normalized().unwrap(); + assert!((c.y + 5.0).abs() < 1e-6 && ((c - Vec2::new(40.0, 0.0)).cross(along).abs() - 5.0).abs() < 1e-6, "{how}: {c:?}"); + // The dimension still measures 40 to the sharp corner, and the sketch solves. + let info = run(&mut s, "sketch.inspect", json!({})); + assert_eq!(info["status"], "solved", "{how}: {info}"); + let (p, q) = sk + .constraints + .iter() + .find_map(|k| match k.kind { + ConstraintKind::DistanceX { p, q, .. } => Some((p, q)), + _ => None, + }) + .unwrap(); + assert!((sk.point(q).unwrap().x - sk.point(p).unwrap().x - 40.0).abs() < 1e-6, "{how}"); + assert!(sk.point(q).unwrap().dist(Vec2::new(40.0, 0.0)) < 1e-6, "{how}"); + } +} + #[test] fn chamfer_two_lines_drawn_to_meet_at_a_point() { let mut s = new_sketch(); @@ -2144,6 +2482,24 @@ fn offset_both_sides_of_a_circle_are_concentric() { assert!(s.execute("sketch.offset", &json!({"curves": [c], "distance": 10, "symmetric": true})).is_err()); } +#[test] +fn an_offset_circle_takes_its_radius_from_the_source() { + // #9264: the offset rebuilds a circle's radius from its source after the solve, but the + // solve still counted that radius as an unknown: a dimensioned circle and its offset + // reported 1 degree of freedom. + let mut s = new_sketch(); + let c = ids(&run(&mut s, "sketch.circle.center", json!({"center": "origin", "radius": 25}))["curves"])[0].clone(); + let d = run(&mut s, "sketch.dimension", json!({"entities": [c], "type": "diameter", "value": "d = 50"})); + assert_eq!(d["sketch"]["dof"], 0, "{d}"); + let o = run(&mut s, "sketch.offset", json!({"curves": [c], "distance": 5, "side": [40, 0]})); + assert_eq!(o["sketch"]["dof"], 0, "the offset circle's radius follows its source: {o}"); + let out = ids(&o["curves"])[0].clone(); + run(&mut s, "parameters.change", json!({"name": "d", "expression": "60"})); + let sk = sketch(&s); + let CurveKind::Circle { r, .. } = sk.curves[sk.curve_index(&out).unwrap()].kind else { panic!("{out} is not a circle") }; + assert!(close(r, 35.0), "30 mm source plus 5 mm: {r}"); +} + #[test] fn offset_refuses_the_combinations_that_would_be_guesswork() { let mut s = new_sketch(); @@ -2308,6 +2664,167 @@ fn an_offset_slot_still_moves_with_the_dimension_that_places_it() { } } +fn capped_line_offset(fixed_cap: bool) -> Session { + let mut s = Session::default(); + run( + &mut s, + "timeline.add", + json!({ + "feature": {"id":1,"name":"Capped offset","type":"sketch","plane":{"type":"origin","name":"XY"},"sketch":{ + "points":[ + {"id":"origin","pos":[0,0],"fixed":true}, + {"id":"end","pos":[0,50],"fixed":false}, + {"id":"oa","pos":[-2,0],"fixed":true}, + {"id":"ob","pos":[-2,50],"fixed":true}, + {"id":"center","pos":[-1,50],"fixed":false}, + {"id":"cap_end","pos":[-2,50],"fixed":fixed_cap} + ], + "curves":[ + {"id":"source","type":"line","a":0,"b":1}, + {"id":"offset","type":"line","a":2,"b":3}, + {"id":"cap","type":"arc","c":4,"a":1,"b":5} + ], + "constraints":[ + {"id":"vertical","type":"vertical","l":0}, + {"id":"length","type":"length","l":0,"value":50,"param":"height"}, + {"id":"source_tangent","type":"tangent","a":0,"b":2}, + {"id":"offset_tangent","type":"tangent","a":1,"b":2}, + {"id":"end_join","type":"coincident","p":3,"q":5} + ], + "offsets":[{"sources":[["source",true]],"outputs":["offset"],"distance":2,"closed":false}] + }}, + "params":[{"name":"height","expression":"50","unit":"mm"}] + }), + ); + run(&mut s, "sketch.edit", json!({"sketch":1})); + s +} + +#[test] +fn a_tangent_cap_follows_its_offset_when_the_source_length_changes() { + // #4823: the cap joins the source to the offset's held endpoint. Solving against the old + // endpoint first makes every length change look contradictory, before the offset can move. + let mut s = capped_line_offset(false); + let check = |s: &Session, height: f64| { + let world = s.world_state(); + let solved = world.sketch(1).unwrap(); + assert!(solved.report.keeps_orientation(), "{:?}", solved.report); + assert!(solved.report.fully_constrained()); + let sk = &solved.sketch; + for (id, expected) in [ + ("origin", Vec2::ZERO), + ("end", Vec2::new(0.0, height)), + ("oa", Vec2::new(-2.0, 0.0)), + ("ob", Vec2::new(-2.0, height)), + ("center", Vec2::new(-1.0, height)), + ("cap_end", Vec2::new(-2.0, height)), + ] { + let point = sk.points.iter().find(|p| p.id == id).unwrap(); + assert!(point.pos.dist(expected) < 1e-6, "{id}: {:?}, expected {expected:?}", point.pos); + } + assert_eq!(sk.constraints.len(), 5); + assert_eq!(sk.offsets.len(), 1); + }; + check(&s, 50.0); + let mut previous = 50.0; + for height in [60.0, 40.0, 70.0] { + run(&mut s, "parameters.change", json!({"name":"height","expression":height.to_string()})); + check(&s, height); + run(&mut s, "edit.undo", json!({})); + check(&s, previous); + run(&mut s, "edit.redo", json!({})); + check(&s, height); + previous = height; + } + let reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + check(&reopened, 70.0); +} + +#[test] +fn an_offset_cap_fixed_at_the_old_height_still_refuses_a_length_change() { + let mut s = capped_line_offset(true); + let before = s.doc.to_json(); + let undo = s.undo.len(); + assert!(s.execute("parameters.change", &json!({"name":"height","expression":"60"})).is_err()); + assert_eq!(s.doc.to_json(), before); + assert_eq!(s.undo.len(), undo); +} + +#[test] +fn a_cap_on_an_offset_arc_keeps_its_radius_through_large_length_edits() { + // Two externally tangent source arcs: radii 3 and 6, centres separated by (4, -sqrt(65)). + // Their inward/outward offsets share those centres; a radius-1 cap closes the 2 mm gap. + // The preliminary keep-start solve used to stretch that cap onto another tangency branch. + let dy = 65.0_f64.sqrt(); + let end_dy = 11.0_f64.sqrt(); + for batch in [false, true] { + let mut s = Session::default(); + run( + &mut s, + "timeline.add", + json!({ + "feature":{"id":1,"name":"Curved cap","type":"sketch","plane":{"type":"origin","name":"XY"},"sketch":{ + "points":[ + {"id":"origin","pos":[0,0],"fixed":true}, + {"id":"end","pos":[0,50]}, + {"id":"bend_center","pos":[3,50]}, + {"id":"bend_join","pos":[3.0+4.0/3.0,50.0-dy/3.0]}, + {"id":"tip_center","pos":[7,50.0-dy]}, + {"id":"tip_end","pos":[2,50.0-dy-end_dy]}, + {"id":"oa","pos":[-2,0],"fixed":true}, + {"id":"ob","pos":[-2,50],"fixed":true}, + {"id":"offset_join","pos":[3.0+20.0/9.0,50.0-dy*5.0/9.0],"fixed":true}, + {"id":"offset_end","pos":[7.0-10.0/3.0,50.0-dy-end_dy*2.0/3.0],"fixed":true}, + {"id":"cap_center","pos":[7.0-25.0/6.0,50.0-dy-end_dy*5.0/6.0]}, + {"id":"cap_end","pos":[7.0-10.0/3.0,50.0-dy-end_dy*2.0/3.0]} + ], + "curves":[ + {"id":"source","type":"line","a":0,"b":1}, + {"id":"bend","type":"arc","c":2,"a":3,"b":1}, + {"id":"tip","type":"arc","c":4,"a":3,"b":5}, + {"id":"offset","type":"line","a":6,"b":7}, + {"id":"bend_offset","type":"arc","c":2,"a":8,"b":7}, + {"id":"tip_offset","type":"arc","c":4,"a":8,"b":9}, + {"id":"cap","type":"arc","c":10,"a":5,"b":11} + ], + "constraints":[ + {"id":"vertical","type":"vertical","l":0}, + {"id":"length","type":"length","l":0,"value":50,"param":"height"}, + {"id":"bend_tangent","type":"tangent","a":0,"b":1}, + {"id":"tip_tangent","type":"tangent","a":1,"b":2}, + {"id":"tip_distance","type":"line_circle_distance","l":0,"c":2,"value":1}, + {"id":"bend_radius","type":"radius","c":1,"value":3}, + {"id":"tip_radius","type":"radius","c":2,"value":6}, + {"id":"end_distance","type":"point_line_distance","p":5,"l":0,"value":2}, + {"id":"cap_tangent","type":"tangent","a":2,"b":6}, + {"id":"end_join","type":"coincident","p":9,"q":11} + ], + "offsets":[{"sources":[["source",true],["bend",false],["tip",true]], + "outputs":["offset","bend_offset","tip_offset"],"distance":2,"closed":false}] + }}, + "params":[{"name":"height","expression":"50","unit":"mm"}] + }), + ); + run(&mut s, "sketch.edit", json!({"sketch":1})); + let initial = sketch(&s); + for height in [55.0, 60.0, 70.0, 40.0] { + let params = + if batch { json!({"changes":{"height":height.to_string()}}) } else { json!({"name":"height","expression":height.to_string()}) }; + run(&mut s, "parameters.change", params); + let world = s.world_state(); + let solved = world.sketch(1).unwrap(); + assert!(solved.report.keeps_orientation() && solved.report.fully_constrained(), "{:?}", solved.report); + for (p, before) in solved.sketch.points.iter().zip(&initial.points) { + let shift = if matches!(p.id.as_str(), "origin" | "oa") { 0.0 } else { height - 50.0 }; + let expected = before.pos + Vec2::new(0.0, shift); + assert!(p.pos.dist(expected) < 1e-6, "batch={batch}, height={height}, {}: {:?}, expected {expected:?}", p.id, p.pos); + assert_eq!(p.fixed, before.fixed, "temporary source holds must not be saved"); + } + assert_eq!(solved.sketch.constraints.len(), 10); + } + } +} + #[test] fn offset_driver_covers_disconnected_selections_and_refuses_bad_values_atomically() { let mut s = new_sketch(); @@ -2644,7 +3161,7 @@ fn trim_piece_is_what_a_trim_there_takes_away() { /// refused (the trim used to report success and leave the curve whole). Trimming away a whole /// offset curve still works, and once the association is broken the trim cuts it. #[test] -fn trim_and_extend_refuse_an_offsets_ends_until_its_association_is_broken() { +fn trim_extend_and_break_refuse_an_offsets_ends_until_its_association_is_broken() { let mut s = new_sketch(); run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]})); let out = ids(&run(&mut s, "sketch.offset", json!({"curves": ["l1", "l2", "l3", "l4"], "distance": "2 mm", "side": [-5, -5]}))["curves"]); @@ -2655,6 +3172,10 @@ fn trim_and_extend_refuse_an_offsets_ends_until_its_association_is_broken() { assert!(e.contains("offset") && e.contains("break the offset association"), "{e}"); let e = s.execute("sketch.extend", &json!({"curve": "l5", "at": [42, -2]})).unwrap_err().to_string(); assert!(e.contains("offset"), "{e}"); + for params in [json!({"curve": "l5", "at": [30, -2]}), json!({"curve": "l5", "points": [[20, -2]]})] { + let e = s.execute("sketch.break", ¶ms).unwrap_err().to_string(); + assert!(e.contains("offset") && e.contains("break the offset association"), "{params}: {e}"); + } assert_eq!(serde_json::to_value(sketch(&s)).unwrap(), before, "a refused trim changes nothing"); run(&mut s, "sketch.offset.break", json!({"curve": "l5"})); @@ -3391,3 +3912,335 @@ fn corner_tools_refuse_all_spatial_curves_with_an_unsupported_message() { } } } + +/// `sketch.arc.tangent` ends on an existing point when `end` names it, so a chain can close, and +/// makes a new point for coordinates as before (#9489). The preview is the arc the command draws. +#[test] +fn a_tangent_arc_can_end_on_an_existing_point() { + let mut s = new_sketch(); + let far = ids(&run(&mut s, "sketch.line", json!({"points": [[20, 20], [40, 20]], "connect": false, "infer": false}))["curves"])[0].clone(); + let line = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]], "connect": false, "infer": false}))["curves"])[0].clone(); + let start = format!("{line}.end"); + let target = format!("{far}.start"); + let sk = sketch(&s); + let preview = crate::cmd::tangent_arc_preview(&sk, &line, &start, Vec2::new(20.0, 20.0)).expect("an arc"); + assert_eq!(preview.curves.len(), 1); + assert!(preview.center(0).unwrap().dist(Vec2::new(10.0, 12.5)) < 1e-9, "{:?}", preview.center(0)); + assert!(crate::cmd::tangent_arc_preview(&sk, &line, &start, Vec2::new(30.0, 0.0)).is_none(), "straight ahead there is no arc"); + + let points = sk.points.len(); + let r = run(&mut s, "sketch.arc.tangent", json!({"start": start, "curve": line, "end": target})); + let arc = ids(&r["curves"])[0].clone(); + let sk = sketch(&s); + assert_eq!(sk.resolve_point(&format!("{arc}.end")), sk.resolve_point(&format!("{far}.start")), "the arc ends on that point"); + assert_eq!(sk.resolve_point(&format!("{arc}.start")), sk.resolve_point(&format!("{line}.end"))); + assert_eq!(sk.points.len(), points + 1, "only the arc's centre is new"); + let ai = sk.curve_index(&arc).unwrap(); + assert!(sk.center(ai).unwrap().dist(Vec2::new(10.0, 12.5)) < 1e-6, "{:?}", sk.center(ai)); + assert!(close(sk.radius(ai).unwrap(), 12.5)); + + // Clockwise: the arc is stored the other way round, and its end is still the named point. + let down = ids(&run(&mut s, "sketch.line", json!({"points": [[0, -50], [10, -50]], "connect": false, "infer": false}))["curves"])[0].clone(); + let mark = ids(&run(&mut s, "sketch.line", json!({"points": [[20, -70], [40, -70]], "connect": false, "infer": false}))["curves"])[0].clone(); + let r = run(&mut s, "sketch.arc.tangent", json!({"start": format!("{down}.end"), "curve": down, "end": format!("{mark}.start")})); + let arc = ids(&r["curves"])[0].clone(); + let sk = sketch(&s); + assert_eq!(sk.resolve_point(&format!("{arc}.end")), sk.resolve_point(&format!("{mark}.start"))); + assert_eq!(sk.resolve_point(&format!("{arc}.start")), sk.resolve_point(&format!("{down}.end"))); + + // A name that is no point, and the arc's own start, are refused and change nothing. + let before = s.doc.to_json(); + assert!(s.execute("sketch.arc.tangent", &json!({"start": format!("{down}.end"), "curve": down, "end": "nowhere"})).is_err()); + assert!(s.execute("sketch.arc.tangent", &json!({"start": format!("{down}.end"), "curve": down, "end": format!("{down}.end")})).is_err()); + assert_eq!(s.doc.to_json(), before); +} + +/// Where a circle of radius `r1` at the origin meets a circle of radius `r2` at `(-d, 0)`, +/// then the same on the right. `(top-left, bottom-left, top-right, bottom-right)`. +fn lobe_corners(r1: f64, r2: f64, d: f64) -> (Vec2, Vec2, Vec2, Vec2) { + let a = (r1 * r1 - r2 * r2 + d * d) / (2.0 * d); + let h = (r1 * r1 - a * a).sqrt(); + (Vec2::new(-a, h), Vec2::new(-a, -h), Vec2::new(a, h), Vec2::new(a, -h)) +} + +fn arc_ends(sk: &solidcraft_sketch::Sketch, id: &str) -> (usize, usize, Vec2, Vec2, Vec2) { + match sk.curves[sk.curve_index(id).unwrap()].kind { + CurveKind::Arc { c, a, b } => (a, b, sk.point(c).unwrap(), sk.point(a).unwrap(), sk.point(b).unwrap()), + ref other => panic!("{id} is {other:?}"), + } +} + +fn line_ends(sk: &solidcraft_sketch::Sketch, id: &str) -> (usize, Vec2, usize, Vec2) { + match sk.curves[sk.curve_index(id).unwrap()].kind { + CurveKind::Line { a, b } => (a, sk.point(a).unwrap(), b, sk.point(b).unwrap()), + ref other => panic!("{id} is {other:?}"), + } +} + +/// Midpoint of the counter-clockwise arc from `a` to `b` around `c`. +fn ccw_mid(c: Vec2, a: Vec2, b: Vec2) -> Vec2 { + let start = (a - c).angle(); + let mut sweep = (b - c).angle() - start; + if sweep <= 1e-12 { + sweep += std::f64::consts::TAU; + } + c + Vec2::from_angle(start + sweep * 0.5) * c.dist(a) +} + +/// Corners whose ends are separate points offset as one chain. An offset that runs past the +/// crossing is trimmed back to it; one that stops short is extended until the curves intersect. +#[test] +fn offset_corners_trim_the_overlap_and_extend_the_gap() { + let (big, small, apart) = (27.5, 12.5, 27.95); + let (tl, bl, tr, br) = lobe_corners(big, small, apart); + let mut s = new_sketch(); + let draw = |s: &mut Session, id: &str, center: [f64; 2], start: Vec2, end: Vec2| { + run( + s, + "sketch.arc.center_point", + json!({ + "id": id, "center": center, "start": [start.x, start.y], "end": [end.x, end.y], "connect": false, + }), + ); + }; + draw(&mut s, "top", [0.0, 0.0], tr, tl); + draw(&mut s, "left", [-apart, 0.0], tl, bl); + draw(&mut s, "bottom", [0.0, 0.0], bl, br); + draw(&mut s, "right", [apart, 0.0], br, tr); + let sk = sketch(&s); + let (top_end, left_start) = (sk.resolve_point("top.end").unwrap(), sk.resolve_point("left.start").unwrap()); + assert_ne!(top_end, left_start, "the corner is two points"); + assert!(sk.point(top_end).unwrap().dist(sk.point(left_start).unwrap()) < 1e-9); + assert!(!sk.constraints.iter().any(|c| matches!(c.kind, ConstraintKind::Coincident { p, q } if p == top_end || q == top_end))); + assert_eq!(profiles(&s).len(), 1, "the four arcs are one profile"); + + // Chain off copies the one arc, source angle and all, so the corner stays open. + let alone = run(&mut s, "sketch.offset", json!({"curves": ["top"], "distance": -3, "chain": false})); + assert_eq!(ids(&alone["curves"]).len(), 1); + let radial = tl.normalized().unwrap() * (big + 3.0); + let (_, _, _, _, open_end) = arc_ends(&sketch(&s), &ids(&alone["curves"])[0]); + assert!(open_end.dist(radial) < 1e-4, "untrimmed end {open_end:?}, radial {radial:?}"); + run(&mut s, "edit.undo", json!({})); + + let out = run(&mut s, "sketch.offset", json!({"curves": ["top"], "distance": -3})); + assert_eq!(ids(&out["curves"]).len(), 4, "{out}"); + assert_eq!(out["sides"].as_array().unwrap().len(), 1); + let sk = sketch(&s); + assert_eq!(sk.offsets.len(), 1); + let rec = &sk.offsets[0]; + assert!(rec.closed); + assert_eq!( + rec.sources.iter().map(|(id, forward)| (id.as_str(), *forward)).collect::>(), + vec![("top", true), ("left", true), ("bottom", true), ("right", true)] + ); + let (meet_tl, meet_bl, meet_tr, meet_br) = lobe_corners(big + 3.0, small + 3.0, apart); + let mut prev_end = arc_ends(&sk, rec.outputs.last().unwrap()).1; + for (id, radius, center) in [ + ("top", big + 3.0, Vec2::ZERO), + ("left", small + 3.0, Vec2::new(-apart, 0.0)), + ("bottom", big + 3.0, Vec2::ZERO), + ("right", small + 3.0, Vec2::new(apart, 0.0)), + ] { + let i = rec.sources.iter().position(|(source, _)| source == id).unwrap(); + let (a, b, c, start, _) = arc_ends(&sk, &rec.outputs[i]); + assert_eq!(prev_end, a, "{id} starts where the previous arc ends"); + assert!((c.dist(start) - radius).abs() < 1e-4 && c.dist(center) < 1e-6, "{id} at {c:?} radius {}", c.dist(start)); + prev_end = b; + } + let (_, _, _, _, trimmed) = arc_ends(&sk, &rec.outputs[0]); + assert!(trimmed.dist(meet_tl) < 1e-4, "top-left corner {trimmed:?}, intersection {meet_tl:?}"); + assert!(trimmed.dist(radial) > 1.0, "trimmed back from the untrimmed end {radial:?} to {trimmed:?}"); + let (_, _, c, a, b) = arc_ends(&sk, &rec.outputs[0]); + let mid = ccw_mid(c, a, b); + assert!(mid.dist(Vec2::new(0.0, big + 3.0)) < 1e-3, "the top arc stays the short one, mid {mid:?}"); + let left_i = rec.sources.iter().position(|(source, _)| source == "left").unwrap(); + let (_, _, c, a, b) = arc_ends(&sk, &rec.outputs[left_i]); + let mid = ccw_mid(c, a, b); + assert!(mid.dist(Vec2::new(-apart - (small + 3.0), 0.0)) < 1e-3, "the left arc stays the outer one, mid {mid:?}"); + // The other three crossings, so a closed chain joins the last corner as well. + let corners: Vec = rec + .outputs + .iter() + .map(|id| { + let (_, _, _, _, end) = arc_ends(&sk, id); + end + }) + .collect(); + for want in [meet_tl, meet_bl, meet_tr, meet_br] { + assert!(corners.iter().any(|q| q.dist(want) < 1e-4), "missing {want:?} in {corners:?}"); + } + + // Editing the distance keeps the corners on the new intersection. + let param = out["param"].as_str().unwrap(); + run(&mut s, "parameters.change", json!({"name": param, "expression": "4 mm"})); + let sk = sketch(&s); + assert!((sk.offsets[0].distance + 4.0).abs() < 1e-6, "distance {}", sk.offsets[0].distance); + let (grown, _, _, _) = lobe_corners(big + 4.0, small + 4.0, apart); + let top_i = sk.offsets[0].sources.iter().position(|(source, _)| source == "top").unwrap(); + let top_out = sk.offsets[0].outputs[top_i].clone(); + let (_, _, _, _, trimmed) = arc_ends(&sk, &top_out); + assert!(trimmed.dist(grown) < 1e-4, "after the edit {trimmed:?}, intersection {grown:?}"); + + // An L of two lines. Negative (outward) stops short of the corner and must be extended. + // Positive runs past it and must be trimmed back. The ends only share a position. + let corner_of = |distance: f64| -> Vec2 { + let mut s = new_sketch(); + run(&mut s, "sketch.line", json!({"points": [[0.0, 0.0], [20.0, 0.0]], "connect": false, "infer": false})); + run(&mut s, "sketch.line", json!({"points": [[20.0, 0.0], [20.0, 20.0]], "connect": false, "infer": false})); + let sk = sketch(&s); + assert_ne!(sk.resolve_point("l1.end").unwrap(), sk.resolve_point("l2.start").unwrap()); + let out = run(&mut s, "sketch.offset", json!({"curves": ["l1"], "distance": distance})); + assert_eq!(ids(&out["curves"]).len(), 2, "distance {distance}: {out}"); + let sk = sketch(&s); + let (first, second) = (&sk.offsets[0].outputs[0], &sk.offsets[0].outputs[1]); + let (_, _, end_i, end) = line_ends(&sk, first); + let (start_i, start, _, _) = line_ends(&sk, second); + assert_eq!(end_i, start_i, "one corner point"); + assert!(end.dist(start) < 1e-9); + end + }; + let extended = corner_of(-3.0); + assert!(extended.dist(Vec2::new(23.0, -3.0)) < 1e-6, "extended to {extended:?}"); + let trimmed = corner_of(3.0); + assert!(trimmed.dist(Vec2::new(17.0, 3.0)) < 1e-6, "trimmed to {trimmed:?}"); + + let mut s = new_sketch(); + run(&mut s, "sketch.line", json!({"points": [[0.0, 0.0], [20.0, 0.0]], "connect": false, "infer": false})); + run(&mut s, "sketch.line", json!({"points": [[20.0, 0.0], [20.0, 20.0]], "connect": false, "infer": false})); + let alone = run(&mut s, "sketch.offset", json!({"curves": ["l1"], "distance": -3, "chain": false})); + assert_eq!(ids(&alone["curves"]).len(), 1); + let (_, _, _, end) = line_ends(&sketch(&s), &ids(&alone["curves"])[0]); + assert!(end.dist(Vec2::new(20.0, -3.0)) < 1e-6, "chain off leaves the gap, end {end:?}"); + + // The same L, joined only by a coincident constraint across a gap the tolerance would not. + let mut s = new_sketch(); + run(&mut s, "sketch.line", json!({"points": [[0.0, 0.0], [20.0, 0.0]], "connect": false, "infer": false})); + run(&mut s, "sketch.line", json!({"points": [[20.5, 0.5], [20.5, 20.0]], "connect": false, "infer": false})); + let gap = { + let sk = sketch(&s); + sk.point(sk.resolve_point("l1.end").unwrap()).unwrap().dist(sk.point(sk.resolve_point("l2.start").unwrap()).unwrap()) + }; + assert!(gap > 0.4, "the ends start {gap} mm apart"); + run(&mut s, "sketch.constraint.fix", json!({"entity": "l1.start"})); + run(&mut s, "sketch.constraint.fix", json!({"entity": "l2.end"})); + run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "l1", "mode": "horizontal"})); + run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "l2", "mode": "vertical"})); + run(&mut s, "sketch.constraint.coincident", json!({"a": "l1.end", "b": "l2.start"})); + let out = run(&mut s, "sketch.offset", json!({"curves": ["l1"], "distance": -3})); + assert_eq!(ids(&out["curves"]).len(), 2, "{out}"); + let sk = sketch(&s); + let (_, _, _, end) = line_ends(&sk, &sk.offsets[0].outputs[0]); + assert!(end.dist(Vec2::new(23.5, -3.0)) < 1e-4, "coincident corner extended to {end:?}"); +} + +#[test] +fn move_copy_drawn_arc_keeps_exact_geometry_and_original() { + use solidcraft_geom::Vec3; + let mut s = new_sketch(); + run(&mut s, "sketch.options", json!({"sketch_3d":true})); + let source = ids(&run(&mut s, "sketch.arc3d", json!({"start":[10,0,3],"through":[0,10,3],"end":[-10,0,3]}))["curves"])[0].clone(); + let id = s.active_sketch.unwrap(); + let original = s.doc.sketch(id).unwrap().wires[0].clone(); + let before = s.doc.to_json(); + let out = run(&mut s, "sketch.move", json!({"entities":[source],"translate":[1.6,0],"copy":true})); + let copied = ids(&out["curves"]); + assert_eq!(copied.len(), 1); + assert_ne!(copied[0], source); + let sk = s.doc.sketch(id).unwrap(); + assert_eq!(sk.wires.len(), 2); + assert_eq!(sk.wires[0], original); + let wire = &sk.wires[1]; + assert_eq!(wire.id, copied[0]); + assert_eq!(wire.space, original.space); + assert_eq!(wire.link, None); + let arc = wire.arc.unwrap(); + assert!(arc.center.dist(Vec3::new(1.6, 0.0, 3.0)) < 1e-9); + assert!(close(arc.radius, 10.0)); + assert!(close(solidcraft_sketch::arc_sweep(&arc), std::f64::consts::PI)); + for (p, q) in wire.fit.iter().zip(&original.fit) { + assert!(p.dist(*q + Vec3::new(1.6, 0.0, 0.0)) < 1e-9); + } + let after = s.doc.to_json(); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.to_json(), before); + run(&mut s, "edit.redo", json!({})); + assert_eq!(s.doc.to_json(), after); + let reopened = solidcraft_doc::format::read(&after).unwrap(); + assert_eq!(reopened.sketch(id).unwrap().wires, s.doc.sketch(id).unwrap().wires); +} + +#[test] +fn move_copy_drawn_arc_turns_in_the_sketch_plane_without_flattening() { + use solidcraft_geom::Vec3; + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane":"YZ"})); + run(&mut s, "sketch.options", json!({"sketch_3d":true})); + let source = ids(&run(&mut s, "sketch.arc3d", json!({"start":[5,12,3],"through":[5,2,13],"end":[5,-8,3]}))["curves"])[0].clone(); + run(&mut s, "sketch.move", json!({"entities":[source],"translate":[4,-1],"angle":90,"center":[2,3],"copy":true})); + let sk = s.doc.sketch(s.active_sketch.unwrap()).unwrap(); + let copy = &sk.wires[1]; + for (p, expected) in copy.fit.iter().zip([Vec3::new(5.0, 6.0, 12.0), Vec3::new(5.0, -4.0, 2.0), Vec3::new(5.0, 6.0, -8.0)]) { + assert!(p.dist(expected) < 1e-9, "{p:?} != {expected:?}"); + } + let arc = copy.arc.unwrap(); + assert!(arc.center.dist(Vec3::new(5.0, 6.0, 2.0)) < 1e-9); + assert!(arc.normal.dist(Vec3::X) < 1e-9); + assert!(close(arc.radius, 10.0)); +} + +#[test] +fn move_copy_drawn_wires_mix_with_planar_curves_and_deduplicate() { + use solidcraft_geom::Vec3; + let mut s = new_sketch(); + run(&mut s, "sketch.options", json!({"sketch_3d":true})); + let curve = ids(&run(&mut s, "sketch.line", json!({"points":[[1,2],[4,2]]}))["curves"])[0].clone(); + let wire = ids(&run(&mut s, "sketch.spline3d", json!({"points":[[1,2,3],[4,6,8],[10,8,9]]}))["curves"])[0].clone(); + let point = run(&mut s, "sketch.point3d", json!({"point":[2,3,4]}))["point"].as_str().unwrap().to_owned(); + let before = sketch(&s); + let out = run(&mut s, "sketch.move", json!({"entities":[wire,curve,point,wire],"translate":[3,-2],"copy":true})); + assert_eq!(ids(&out["curves"]).len(), 3); + let after = sketch(&s); + assert_eq!(after.curves.len(), before.curves.len() + 1); + assert_eq!(after.wires.len(), before.wires.len() + 2); + for (original, copy) in before.wires.iter().zip(after.wires.iter().skip(2)) { + assert_eq!(after.wires.iter().find(|w| w.id == original.id).unwrap(), original); + assert_eq!(copy.space, original.space); + assert_eq!(copy.arc, None); + for (p, q) in copy.fit.iter().zip(&original.fit) { + assert!(p.dist(*q + Vec3::new(3.0, -2.0, 0.0)) < 1e-9); + } + } + let copied = &after.curves[1]; + let CurveKind::Line { a, b } = copied.kind else { panic!("expected a copied line") }; + assert!(after.point(a).unwrap().dist(Vec2::new(4.0, 0.0)) < 1e-9); + assert!(after.point(b).unwrap().dist(Vec2::new(7.0, 0.0)) < 1e-9); +} + +#[test] +fn move_copy_drawn_wire_failures_leave_the_design_and_history_unchanged() { + let mut s = new_sketch(); + run(&mut s, "sketch.options", json!({"sketch_3d":true})); + let wire = ids(&run(&mut s, "sketch.arc3d", json!({"start":[10,0,0],"through":[0,10,0],"end":[-10,0,0]}))["curves"])[0].clone(); + let original = s.doc.to_json(); + let history = (s.undo.len(), s.redo.len()); + for params in [ + json!({"entities":[wire],"copy":true,"translate":[1e9,0]}), + json!({"entities":[wire,"missing"],"copy":true,"translate":[1,0]}), + json!({"entities":[],"copy":true}), + ] { + assert!(s.execute("sketch.move", ¶ms).is_err()); + assert_eq!(s.doc.to_json(), original); + assert_eq!((s.undo.len(), s.redo.len()), history); + } + // An unlinked reference wire has no editable fit points: Copy must not convert its + // samples to a fitted spline and silently change the curve's shape. + let id = s.active_sketch.unwrap(); + s.doc_mut().sketch_mut(id).unwrap().wires[0].fit.clear(); + s.doc_mut().sketch_mut(id).unwrap().wires[0].arc = None; + s.refresh(); + let original = s.doc.to_json(); + let error = s.execute("sketch.move", &json!({"entities":[wire],"copy":true})).unwrap_err().to_string(); + assert!(error.contains("drawn 3D"), "{error}"); + assert_eq!(s.doc.to_json(), original); + assert_eq!((s.undo.len(), s.redo.len()), history); +} diff --git a/crates/engine/src/cmd/sketch_point_expr_tests.rs b/crates/engine/src/cmd/sketch_point_expr_tests.rs index 4a3d3c8199..678fe5038a 100644 --- a/crates/engine/src/cmd/sketch_point_expr_tests.rs +++ b/crates/engine/src/cmd/sketch_point_expr_tests.rs @@ -187,3 +187,216 @@ fn a_tangent_from_a_placed_point_keeps_the_side_it_was_drawn_on() { } assert!(flipped.is_empty(), "the tangent went over to the other side, without a word, at {flipped:?}"); } + +#[test] +fn a_rectangle_corner_typed_as_an_expression_follows_its_parameters() { + let mut s = Session::default(); + run(&mut s, "parameters.add", json!({"name": "W", "expression": "40 mm"})); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"})); + let c = run(&mut s, "sketch.rectangle.center", json!({"center": [0, 0], "corner": ["W / 2", "W / 4"]})); + // A second one, from two corners, one of them mixing a number and an expression. + let t = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [100, 100], "p1": ["100 + W", 100 + 10]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch": "S", "distance": 10})); + no_trouble(&s); + let corner = |r: &Value, k: &str| r["corners"][k].as_str().unwrap_or_default().to_string(); + assert_eq!(solved(&s, &corner(&c, "top_right")), Vec2::new(20.0, 10.0)); + assert_eq!(solved(&s, &corner(&t, "top_right")), Vec2::new(140.0, 110.0)); + run(&mut s, "parameters.change", json!({"name": "W", "expression": "80 mm"})); + no_trouble(&s); + assert_eq!(solved(&s, &corner(&c, "bottom_left")), Vec2::new(-40.0, -20.0)); + assert_eq!(solved(&s, &corner(&c, "top_right")), Vec2::new(40.0, 20.0)); + // The other corners are solved, not placed: equal within the solver's tolerance. + let br = solved(&s, &corner(&t, "bottom_right")); + assert!((br.x - 180.0).abs() < 1e-6 && (br.y - 100.0).abs() < 1e-6, "{br:?}"); + assert!((volume(&mut s) - (80.0 * 40.0 + 80.0 * 10.0) * 10.0).abs() < 1e-6); + // The design file keeps them. + let mut r = reopened(&s); + no_trouble(&r); + assert!((volume(&mut r) - (80.0 * 40.0 + 80.0 * 10.0) * 10.0).abs() < 1e-6); + // An expression that cannot be worked out is refused, and draws nothing. + run(&mut s, "sketch.edit", json!({"sketch": "S"})); + let points = s.doc.sketch(s.doc.find_feature("S").unwrap().id).unwrap().points.len(); + assert!(s.execute("sketch.rectangle.center", &json!({"center": [0, 0], "corner": ["nope", 5]})).is_err()); + assert!(s.execute("sketch.rectangle.two_point", &json!({"p0": [0, 0], "p1": [5, "nope"]})).is_err()); + assert_eq!(s.doc.sketch(s.doc.find_feature("S").unwrap().id).unwrap().points.len(), points); +} + +#[test] +fn a_circle_size_typed_as_an_expression_is_a_dimension_that_follows_its_parameters() { + let mut s = Session::default(); + run(&mut s, "parameters.add", json!({"name": "X", "expression": "10 mm"})); + run(&mut s, "parameters.add", json!({"name": "R", "expression": "4 mm"})); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"})); + run(&mut s, "sketch.circle.center", json!({"center": ["X", 0], "radius": "R", "id": "a"})); + run(&mut s, "sketch.circle.center", json!({"center": [50, 0], "diameter": "R * 4", "id": "b"})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch": "S", "distance": 10})); + no_trouble(&s); + let radius = |s: &Session, id: &str| { + let f = s.doc.find_feature("S").unwrap().id; + let st = s.model.state(); + let sk = &st.sketch(f).unwrap().sketch; + let c = sk.curves.iter().find(|c| c.id == id).unwrap(); + match c.kind { + solidcraft_sketch::CurveKind::Circle { r, .. } => r, + _ => f64::NAN, + } + }; + assert!((radius(&s, "a") - 4.0).abs() < 1e-9 && (radius(&s, "b") - 8.0).abs() < 1e-9); + let before = volume(&mut s); + run(&mut s, "parameters.change", json!({"name": "R", "expression": "9 mm"})); + no_trouble(&s); + assert!((radius(&s, "a") - 9.0).abs() < 1e-9 && (radius(&s, "b") - 18.0).abs() < 1e-9); + assert!(volume(&mut s) > before); + // A value that is no size is refused with its own message, not a missing one. + run(&mut s, "sketch.edit", json!({"sketch": "S"})); + for bad in [json!({"center": [0, 0], "radius": "nope"}), json!({"center": [0, 0], "radius": "0 mm"}), json!({"center": [0, 0], "radius": true})] { + let e = s.execute("sketch.circle.center", &bad).err().map(|e| e.to_string()).unwrap_or_default(); + assert!(!e.is_empty() && !e.contains("needs `radius` or `diameter`"), "{bad}: {e}"); + } + // `radius` wins over `diameter` whatever its form: a bad one is refused, not passed over. + for bad in [json!({"center": [0, 0], "radius": true, "diameter": 10}), json!({"center": [0, 0], "radius": [1], "diameter": "R"})] { + let e = s.execute("sketch.circle.center", &bad).err().map(|e| e.to_string()).unwrap_or_default(); + assert!(e.contains("`radius` must be a number"), "{bad}: {e}"); + } + let e = s.execute("sketch.circle.center", &json!({"center": [0, 0]})).err().map(|e| e.to_string()).unwrap_or_default(); + assert!(e.contains("needs `radius` or `diameter`"), "{e}"); + run(&mut s, "sketch.circle.center", json!({"center": [100, 0], "radius": "R", "diameter": 30, "id": "c"})); + run(&mut s, "sketch.circle.center", json!({"center": [150, 0], "radius": 5, "diameter": "R * 2", "id": "d"})); + run(&mut s, "sketch.finish", json!({})); + assert!((circle_radius(&s, "c") - 9.0).abs() < 1e-9 && (circle_radius(&s, "d") - 5.0).abs() < 1e-9); +} + +fn circle_radius(s: &Session, id: &str) -> f64 { + let f = s.doc.find_feature("S").unwrap().id; + let st = s.model.state(); + let sk = &st.sketch(f).unwrap().sketch; + match sk.curves.iter().find(|c| c.id == id).map(|c| &c.kind) { + Some(solidcraft_sketch::CurveKind::Circle { r, .. }) => *r, + _ => f64::NAN, + } +} + +fn corner_at(s: &Session, r: &Value, k: &str) -> Vec2 { + solved(s, r["corners"][k].as_str().unwrap_or_default()) +} + +#[test] +fn a_centre_rectangle_follows_its_parameters_from_any_quadrant_and_an_expression_centre() { + let mut s = Session::default(); + run(&mut s, "parameters.add", json!({"name": "W", "expression": "40 mm"})); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"})); + let rects: Vec = [(1.0, 1.0), (-1.0, 1.0), (1.0, -1.0), (-1.0, -1.0)] + .iter() + .enumerate() + .map(|(i, (sx, sy))| { + let at = 200.0 * i as f64; + let corner = json!([format!("{at} + {sx} * W / 2"), format!("{sy} * W / 4")]); + run( + &mut s, + "sketch.rectangle.center", // The centre is held by an expression (a plain-number centre stays free: it moves with the solver). + json!({"center": [format!("{at} + 0 * W"), "0 * W"], "corner": corner}), + ) + }) + .collect(); + // An expression centre too, with a corner expressed from it. + let c = run(&mut s, "sketch.rectangle.center", json!({"center": [1000, "W"], "corner": [1010, "W + 5"]})); + let e = run(&mut s, "sketch.rectangle.center", json!({"center": ["W * 20", "-W"], "corner": ["W * 20 + 10", "-W - 5"]})); + run(&mut s, "sketch.finish", json!({})); + no_trouble(&s); + run(&mut s, "parameters.change", json!({"name": "W", "expression": "80 mm"})); + no_trouble(&s); + for (i, r) in rects.iter().enumerate() { + let at = 200.0 * i as f64; + let (bl, tr) = (corner_at(&s, r, "bottom_left"), corner_at(&s, r, "top_right")); + assert!((bl.x - (at - 40.0)).abs() < 1e-6 && (bl.y + 20.0).abs() < 1e-6, "{i}: {bl:?}"); + assert!((tr.x - (at + 40.0)).abs() < 1e-6 && (tr.y - 20.0).abs() < 1e-6, "{i}: {tr:?}"); + } + let (bl, tr) = (corner_at(&s, &c, "bottom_left"), corner_at(&s, &c, "top_right")); + assert!((bl.x - 990.0).abs() < 1e-6 && (bl.y - 75.0).abs() < 1e-6 && (tr.x - 1010.0).abs() < 1e-6 && (tr.y - 85.0).abs() < 1e-6, "{bl:?} {tr:?}"); + let (bl, tr) = (corner_at(&s, &e, "bottom_left"), corner_at(&s, &e, "top_right")); + assert!( + (bl.x - 1590.0).abs() < 1e-6 && (bl.y + 85.0).abs() < 1e-6 && (tr.x - 1610.0).abs() < 1e-6 && (tr.y + 75.0).abs() < 1e-6, + "{bl:?} {tr:?}" + ); +} + +#[test] +fn a_two_point_rectangle_drawn_from_swapped_corners_follows_its_parameters() { + let mut s = Session::default(); + run(&mut s, "parameters.add", json!({"name": "W", "expression": "40 mm"})); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"})); + // p0 is the top right, p1 the bottom left: each coordinate is kept with its corner. + let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": ["W", "W / 2"], "p1": [0, 0]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "parameters.change", json!({"name": "W", "expression": "60 mm"})); + no_trouble(&s); + assert_eq!(corner_at(&s, &r, "top_right"), Vec2::new(60.0, 30.0)); + let bl = corner_at(&s, &r, "bottom_left"); + assert!(bl.x.abs() < 1e-9 && bl.y.abs() < 1e-9, "{bl:?}"); +} + +#[test] +fn a_size_expression_reads_in_the_design_unit_and_one_naming_no_parameter_is_a_number() { + let mut s = Session::default(); + run(&mut s, "document.units", json!({"units": "in"})); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"})); + let params = s.doc.params.len(); + run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": "1", "id": "a"})); + run(&mut s, "sketch.circle.center", json!({"center": [50, 0], "radius": "2 in", "id": "b"})); + run(&mut s, "sketch.circle.center", json!({"center": [100, 0], "diameter": "1", "id": "c"})); + // An expression naming no parameter is the number it comes to: no dimension behind it. + assert_eq!(s.doc.params.len(), params); + assert!( + (circle_radius(&s, "a") - 25.4).abs() < 1e-9 && (circle_radius(&s, "b") - 50.8).abs() < 1e-9 && (circle_radius(&s, "c") - 12.7).abs() < 1e-9 + ); + run(&mut s, "parameters.add", json!({"name": "R", "expression": "1 in"})); + run(&mut s, "sketch.circle.center", json!({"center": [150, 0], "radius": "R / 2", "id": "d"})); + run(&mut s, "sketch.finish", json!({})); + no_trouble(&s); + assert!((circle_radius(&s, "d") - 12.7).abs() < 1e-9); + run(&mut s, "parameters.change", json!({"name": "R", "expression": "2 in"})); + no_trouble(&s); + assert!((circle_radius(&s, "d") - 25.4).abs() < 1e-9); + // The 2-tangent circle reads a bare number typed as text the same way. + run(&mut s, "sketch.edit", json!({"sketch": "S"})); + let x = run(&mut s, "sketch.line", json!({"points": [[200, 0], [300, 0]]})); + let y = run(&mut s, "sketch.line", json!({"points": [[200, 0], [200, 100]], "connect": false})); + let t = run(&mut s, "sketch.circle.two_tangent", json!({"curves": [x["curves"][0], y["curves"][0]], "radius": "1", "near": [225, 25]})); + let id = t["curves"][0].as_str().unwrap_or_default().to_string(); + assert!((circle_radius_live(&s, &id) - 25.4).abs() < 1e-6); +} + +/// The radius of circle `id` in the sketch being edited. +fn circle_radius_live(s: &Session, id: &str) -> f64 { + let f = s.doc.find_feature("S").unwrap().id; + match s.doc.sketch(f).ok().and_then(|sk| sk.curves.iter().find(|c| c.id == id).map(|c| c.kind.clone())) { + Some(solidcraft_sketch::CurveKind::Circle { r, .. }) => r, + _ => f64::NAN, + } +} + +#[test] +fn a_rejected_size_or_corner_expression_leaves_nothing_behind() { + let mut s = Session::default(); + run(&mut s, "parameters.add", json!({"name": "W", "expression": "40 mm"})); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"})); + let f = s.doc.find_feature("S").unwrap().id; + let count = |s: &Session| { + let sk = s.doc.sketch(f).unwrap(); + (sk.points.len(), sk.curves.len(), sk.constraints.len(), s.doc.params.len()) + }; + let before = count(&s); + let tries = [ + ("sketch.circle.center", json!({"center": ["W", 0], "radius": "W - 40"})), + ("sketch.circle.center", json!({"center": ["W", 0], "radius": "nope"})), + ("sketch.circle.center", json!({"center": ["W", 0], "radius": "W", "id": "bad|id"})), + ("sketch.rectangle.center", json!({"center": ["W", 0], "corner": ["W", 5]})), + ("sketch.rectangle.two_point", json!({"p0": ["W", 0], "p1": ["W", 5]})), + ]; + for (cmd, p) in tries { + assert!(s.execute(cmd, &p).is_err(), "{cmd} {p}"); + assert_eq!(count(&s), before, "{cmd} {p}"); + } +} diff --git a/crates/engine/src/cmd/sketch_project.rs b/crates/engine/src/cmd/sketch_project.rs index d372a3e292..8750ca543d 100644 --- a/crates/engine/src/cmd/sketch_project.rs +++ b/crates/engine/src/cmd/sketch_project.rs @@ -7,7 +7,7 @@ use serde_json::{Value, json}; use solidcraft_doc::project::resolve; use solidcraft_doc::{Document, FeatureKind}; use solidcraft_geom::{Plane, Vec2, Vec3}; -use solidcraft_sketch::{ConstraintKind, LinkKind, LinkSource, Sketch, nearest_crossing}; +use solidcraft_sketch::{ConstraintKind, LinkKind, LinkSource, Sketch}; use super::sketch_store; use super::{CommandSpec, in_sketch}; @@ -685,9 +685,9 @@ pub(super) fn snap_points(s: &mut Session, p: &Value, cmd: &str) -> Result = TypedContract::new(); +const DRAFT: TypedContract = TypedContract::new(); +const ENVIRONMENT: TypedContract = TypedContract::new(); +const ACCESSIBILITY: TypedContract = TypedContract::new(); +const CURVATURE: TypedContract = TypedContract::new(); +const CONTINUITY: TypedContract = TypedContract::new(); + /// Stations along each edge when the call does not say (`samples`). const DEFAULT_SAMPLES: usize = 33; /// Tolerances a seam is judged against when the call does not say: a micron of position gap, a @@ -20,42 +28,62 @@ const MAX_PICKS: usize = 512; const MAX_BODIES: usize = 64; pub static COMMANDS: &[CommandSpec] = &[ - CommandSpec::new("inspect.zebra", "Zebra Analysis", zebra) + CommandSpec::new("inspect.zebra", "Zebra Analysis", crate::checked!("inspect.zebra", zebra)) + .contract(&ZEBRA) + .output(output_schema_for::) .at("SKETCH", "INSPECT") .icon("zebra") .noundo() .params("stripes?: number across the view (default 12); clear?: true turns the analysis off"), - CommandSpec::new("inspect.draft", "Draft Analysis", draft) + CommandSpec::new("inspect.draft", "Draft Analysis", crate::checked!("inspect.draft", draft)) + .contract(&DRAFT) + .output(output_schema_for::) .at("SKETCH", "INSPECT") .icon("draft_analysis") .noundo() .params("pull?: [x,y,z] direction (default Z); angle?: degrees (default 1); clear?: true turns the analysis off"), - CommandSpec::new("inspect.environment_map", "Environment Map Analysis", |s, p| set(s, p, SurfaceAnalysis::Environment)) + CommandSpec::new("inspect.environment_map", "Environment Map Analysis", crate::checked!("inspect.environment_map", environment)) + .contract(&ENVIRONMENT) + .output(output_schema_for::) .at("SKETCH", "INSPECT") .icon("environment_map") .noundo() .params("clear?: true turns the analysis off"), - CommandSpec::new("inspect.accessibility", "Accessibility Analysis", accessibility) + CommandSpec::new("inspect.accessibility", "Accessibility Analysis", crate::checked!("inspect.accessibility", accessibility)) + .contract(&ACCESSIBILITY) + .output(output_schema_for::) .at("SKETCH", "INSPECT") .icon("accessibility") .noundo() .params("direction?: [x,y,z] the tool comes from (default Z, from above); clear?: true turns the analysis off"), - CommandSpec::new("inspect.curvature_map", "Curvature Map Analysis", curvature_map) + CommandSpec::new("inspect.curvature_map", "Curvature Map Analysis", crate::checked!("inspect.curvature_map", curvature_map)) + .contract(&CURVATURE) + .output(output_schema_for::) .at("SKETCH", "INSPECT") .icon("curvature_map") .noundo() .params("radius?: mm shown red (default: a tenth of the model's size); clear?: true turns the analysis off"), - CommandSpec::new("inspect.continuity", "Continuity Analysis", continuity).at("SURFACE", "INSPECT").icon("continuity").noundo().params( - "body?: name (default: the visible bodies); edges?: [x,y,z] points or {\"index\": i} (default: every edge of the body; \ + CommandSpec::new("inspect.continuity", "Continuity Analysis", crate::checked!("inspect.continuity", continuity)) + .contract(&CONTINUITY) + .output(output_schema_for::) + .at("SURFACE", "INSPECT") + .icon("continuity") + .noundo() + .params( + "body?: name (default: the visible bodies); edges?: [x,y,z] points or {\"index\": i} (default: every edge of the body; \ a pick must match one body); samples?: 3…257 stations along each edge, ends included (default 33); \ gap_tolerance_mm? (default 0.001), angle_tolerance_deg? (default 1), curvature_tolerance_per_mm? (default 0.01); \ add?: true keeps the seams already shaded and adds these (measuring an edge again replaces it); \ clear?: true turns the analysis off. → each edge's worst gap (G0), normal angle (G1) and curvature jump (G2) as \ {value, at: [x,y,z], edge_fraction}, its verdict (smooth, curvature_jump, sharp or gap) and a summary; \ the seams are shaded in the viewport", - ), + ), ]; +fn environment(s: &mut Session, p: &Value) -> Result { + set(s, p, SurfaceAnalysis::Environment) +} + fn set(s: &mut Session, p: &Value, a: SurfaceAnalysis) -> Result { if bool_(p, "clear").unwrap_or(false) { s.analysis = None; @@ -64,7 +92,15 @@ fn set(s: &mut Session, p: &Value, a: SurfaceAnalysis) -> Result { } s.status_analysis_changed((!bool_(p, "clear").unwrap_or(false)).then_some(crate::status_items::StatusId::SurfaceAnalysis)); s.revision += 1; - Ok(json!({ "analysis": s.analysis })) + let vector = |v: Vec3| [v.x, v.y, v.z]; + let analysis = s.analysis.map(|a| match a { + SurfaceAnalysis::Zebra { stripes } => contract::Analysis::Zebra { stripes }, + SurfaceAnalysis::Draft { pull, angle } => contract::Analysis::Draft { pull: vector(pull), angle }, + SurfaceAnalysis::Curvature { radius } => contract::Analysis::Curvature { radius }, + SurfaceAnalysis::Environment => contract::Analysis::Environment, + SurfaceAnalysis::Access { dir } => contract::Analysis::Access { dir: vector(dir) }, + }); + serde_json::to_value(contract::AnalysisResult { analysis, param_warnings: Vec::new() }).map_err(|e| bad("surface analysis", e.to_string())) } fn zebra(s: &mut Session, p: &Value) -> Result { @@ -120,7 +156,12 @@ fn continuity(s: &mut Session, p: &Value) -> Result { s.continuity.clear(); s.status_analysis_changed(None); s.revision += 1; - return Ok(json!({ "analysis": "continuity", "cleared": true })); + return serde_json::to_value(contract::ContinuityResult::Cleared { + analysis: "continuity".into(), + cleared: true, + param_warnings: Vec::new(), + }) + .map_err(|e| bad(cmd, e.to_string())); } let samples = match p.get("samples") { None => DEFAULT_SAMPLES, @@ -180,25 +221,49 @@ fn continuity(s: &mut Session, p: &Value) -> Result { let mut rows = Vec::new(); for seam in &report.seams { summary.count(seam.verdict); - seams.push(ContinuitySeam { body: body.name.clone(), edge: seam.edge, points: seam.points.clone(), verdict: seam.verdict }); - rows.push(json!({ - "edge": seam.edge, - "length_mm": seam.length, - "samples": seam.samples, - "gap_mm": seam.gap_mm, - "normal_angle_deg": seam.normal_angle_deg, - "curvature_jump_per_mm": seam.curvature_jump_per_mm, - "verdict": seam.verdict, - })); + seams.push(ContinuitySeam { + body: body.name.clone(), + edge: seam.edge, + points: seam.points.clone(), + verdict: seam.verdict, + details: crate::status_details::ContinuityDetail { + body: body.name.clone(), + edge: seam.edge, + samples: seam.samples, + gap_tolerance_mm: tolerances.gap_mm, + angle_tolerance_deg: tolerances.angle_deg, + curvature_tolerance_per_mm: tolerances.curvature_per_mm, + gap_mm: seam.gap_mm.value, + normal_angle_deg: seam.normal_angle_deg.value, + curvature_jump_per_mm: seam.curvature_jump_per_mm.value, + length_mm: seam.length, + verdict: seam.verdict.into(), + gap_at: [seam.gap_mm.at.x, seam.gap_mm.at.y, seam.gap_mm.at.z], + gap_fraction: seam.gap_mm.edge_fraction, + normal_angle_at: [seam.normal_angle_deg.at.x, seam.normal_angle_deg.at.y, seam.normal_angle_deg.at.z], + normal_angle_fraction: seam.normal_angle_deg.edge_fraction, + curvature_jump_at: [seam.curvature_jump_per_mm.at.x, seam.curvature_jump_per_mm.at.y, seam.curvature_jump_per_mm.at.z], + curvature_jump_fraction: seam.curvature_jump_per_mm.edge_fraction, + skipped: report.skipped, + unmeasured: report.unmeasured, + current: true, + geometry_key: body.geometry_key(), + feature: body.feature, + }, + }); + rows.push(contract::EdgeReport { + edge: seam.edge, + length_mm: seam.length, + samples: seam.samples, + gap_mm: metric(seam.gap_mm), + normal_angle_deg: metric(seam.normal_angle_deg), + curvature_jump_per_mm: metric(seam.curvature_jump_per_mm), + verdict: verdict(seam.verdict), + }); } summary.skipped += report.skipped; summary.unmeasured += report.unmeasured; - bodies.push(json!({ - "body": body.name, - "edges": rows, - "skipped": report.skipped, - "unmeasured": report.unmeasured, - })); + bodies.push(contract::BodyReport { body: body.name.clone(), edges: rows, skipped: report.skipped, unmeasured: report.unmeasured }); } if bool_(p, "add").unwrap_or(false) { // The seams shown stay; an edge measured again takes its new reading. @@ -209,26 +274,40 @@ fn continuity(s: &mut Session, p: &Value) -> Result { } s.status_analysis_changed(Some(crate::status_items::StatusId::Continuity)); s.revision += 1; - Ok(json!({ - "analysis": "continuity", - "samples": samples, - "tolerances": { - "gap_mm": tolerances.gap_mm, - "angle_deg": tolerances.angle_deg, - "curvature_per_mm": tolerances.curvature_per_mm, + serde_json::to_value(contract::ContinuityResult::Measured { + analysis: "continuity".into(), + samples, + tolerances: contract::Tolerances { + gap_mm: tolerances.gap_mm, + angle_deg: tolerances.angle_deg, + curvature_per_mm: tolerances.curvature_per_mm, }, - "bodies": bodies, - "summary": { - "edges": summary.edges, - "smooth": summary.smooth, - "curvature_jump": summary.curvature_jump, - "sharp": summary.sharp, - "gap": summary.gap, - "skipped": summary.skipped, - "unmeasured": summary.unmeasured, - "worst": summary.worst(), + bodies, + summary: contract::Summary { + edges: summary.edges, + smooth: summary.smooth, + curvature_jump: summary.curvature_jump, + sharp: summary.sharp, + gap: summary.gap, + skipped: summary.skipped, + unmeasured: summary.unmeasured, + worst: summary.worst().map(verdict), }, - })) + param_warnings: Vec::new(), + }) + .map_err(|e| bad(cmd, e.to_string())) +} + +fn verdict(value: kernel::SeamVerdict) -> contract::Verdict { + match value { + kernel::SeamVerdict::Smooth => contract::Verdict::Smooth, + kernel::SeamVerdict::CurvatureJump => contract::Verdict::CurvatureJump, + kernel::SeamVerdict::Sharp => contract::Verdict::Sharp, + kernel::SeamVerdict::Gap => contract::Verdict::Gap, + } +} +fn metric(value: kernel::SeamWorst) -> contract::Metric { + contract::Metric { value: value.value, at: [value.at.x, value.at.y, value.at.z], edge_fraction: value.edge_fraction } } /// A tolerance parameter. A value that is given but unusable (NaN, infinity, negative, a @@ -682,6 +761,59 @@ mod tests { assert!(s.status_order.is_empty(), "new design resets transient chronology"); } + #[test] + fn continuity_details_never_validate_old_matching_geometry_during_a_live_drag() { + use std::sync::Arc; + let mut s = Session::default(); + for (command, params) in [ + ("sketch.create", json!({"plane":"XY"})), + ("sketch.rectangle.two_point", json!({"p0":[5,5],"p1":[25,20]})), + ("sketch.dimension", json!({"entities":["l1"],"value":20})), + ("sketch.dimension", json!({"entities":["l2"],"value":15})), + ("sketch.finish", json!({})), + ("solid.extrude", json!({"distance":5})), + ("inspect.continuity", json!({"samples":5})), + ("sketch.edit", json!({"sketch":"Sketch1"})), + ] { + s.execute(command, ¶ms).unwrap(); + } + let records = s.continuity.clone(); + assert!(!records.is_empty()); + assert!(s.continuity_details().iter().all(|row| row.current)); + let before_drag = s.model.state(); + s.execute("sketch.drag", &json!({"entity":"l1.start","from":[5,5],"to":[4,5],"live":true})).unwrap(); + assert!(s.model.is_stale()); + // The live solver replaces ModelState to show the moved sketch, while retaining + // its old downstream B-rep body. That body/key, not the whole container Arc, + // is the misleading geometry the pure descriptor must refuse. + let retained_model = s.model.state(); + assert!(before_drag.body(&records[0].body).unwrap().body.same_shape(&retained_model.body(&records[0].body).unwrap().body)); + assert_eq!( + retained_model.body(&records[0].body).unwrap().geometry_key(), + records[0].details.geometry_key, + "this real pending edit retains exactly the misleading old matching geometry key" + ); + let document = s.doc.clone(); + let before = (s.revision, s.undo.len(), s.redo.len(), s.is_dirty(), s.evaluations, s.status_order.clone()); + for _ in 0..3 { + let reply = s.execute("view.status_details", &json!({})).unwrap(); + assert!(reply["continuity"].as_array().unwrap().iter().all(|row| row["current"] == false)); + assert!(s.continuity_target_points(&records[0].body, records[0].edge).is_none()); + } + assert!(s.model.is_stale()); + assert!(Arc::ptr_eq(&document, &s.doc)); + assert!(Arc::ptr_eq(&retained_model, &s.model.state())); + assert_eq!(s.continuity, records, "query does not clean or fabricate measurements"); + assert_eq!((s.revision, s.undo.len(), s.redo.len(), s.is_dirty(), s.evaluations, s.status_order.clone()), before); + s.execute("sketch.drag_end", &json!({})).unwrap(); + s.execute("sketch.finish", &json!({})).unwrap(); + s.execute("inspect.continuity", &json!({"samples":5})).unwrap(); + assert!(!s.model.is_stale()); + assert!(s.continuity_details().iter().all(|row| row.current)); + let fresh = &s.continuity[0]; + assert!(s.continuity_target_points(&fresh.body, fresh.edge).is_some(), "normal fresh evaluation/measurement restores valid targets"); + } + /// The edges of the first body of a continuity reply. fn edges(v: &Value) -> &Vec { v["bodies"][0]["edges"].as_array().unwrap_or_else(|| panic!("no edges in {v}")) @@ -827,6 +959,112 @@ mod tests { } /// Hostile parameters are refused with an error, never a panic. + #[test] + fn continuity_details_keep_successful_per_record_settings_and_query_is_pure() { + let mut s = Session::default(); + boxed(&mut s, 10.0); + let first = s + .execute( + "inspect.continuity", + &json!({"edges":[[5,0,0]],"samples":5,"gap_tolerance_mm":0.002,"angle_tolerance_deg":2,"curvature_tolerance_per_mm":0.03}), + ) + .unwrap(); + let old = s.continuity[0].clone(); + s.execute( + "inspect.continuity", + &json!({"edges":[[0,5,0]],"samples":9,"gap_tolerance_mm":0.007,"angle_tolerance_deg":4,"curvature_tolerance_per_mm":0.06,"add":true}), + ) + .unwrap(); + let rows = s.continuity_details(); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].samples, 5); + assert_eq!(rows[0].gap_tolerance_mm, 0.002); + assert_eq!(rows[1].samples, 9); + assert_eq!(rows[1].angle_tolerance_deg, 4.0); + assert!(rows.iter().all(|row| row.current)); + assert_eq!(rows[0].gap_mm, first["bodies"][0]["edges"][0]["gap_mm"]["value"].as_f64().unwrap()); + assert_eq!(rows[0].gap_at, serde_json::from_value::<[f64; 3]>(first["bodies"][0]["edges"][0]["gap_mm"]["at"].clone()).unwrap()); + let before = (s.revision, s.doc.to_json(), s.undo.len(), s.status_activation.clone(), s.continuity.clone(), s.is_dirty()); + for _ in 0..3 { + let query = s.execute("view.status_details", &json!({})).unwrap(); + assert_eq!(query["continuity"][0]["samples"], 5); + assert_eq!(query["continuity"][1]["samples"], 9); + } + assert_eq!((s.revision, s.doc.to_json(), s.undo.len(), s.status_activation.clone(), s.continuity.clone(), s.is_dirty()), before); + assert!(s.execute("view.status_details", &json!({"extra":true})).is_err()); + assert!(s.execute("inspect.continuity", &json!({"body":old.body,"edges":[{"index":old.edge}],"samples":2,"add":true})).is_err()); + assert_eq!(s.continuity, before.4, "failed live update is atomic across mixed settings"); + s.execute("inspect.continuity",&json!({"body":old.body,"edges":[{"index":old.edge}],"samples":7,"gap_tolerance_mm":old.details.gap_tolerance_mm,"angle_tolerance_deg":old.details.angle_tolerance_deg,"curvature_tolerance_per_mm":old.details.curvature_tolerance_per_mm,"add":true})).unwrap(); + let remaining = s.continuity.iter().find(|row| row.edge != old.edge).unwrap(); + assert_eq!(remaining.details, rows[1]); + assert_eq!(s.continuity.iter().find(|row| row.edge == old.edge).unwrap().details.samples, 7); + } + + #[test] + fn continuity_targets_follow_geometry_identity_rather_than_view_revision() { + let mut s = Session::default(); + boxed(&mut s, 10.0); + s.execute("inspect.continuity", &json!({"edges":[[5,0,0]],"samples":5})).unwrap(); + let seam = s.continuity[0].clone(); + let revision = s.revision; + s.execute("view.apply", &json!({"camera":{"target":[1,2,3],"yaw":0,"pitch":0,"distance":40}})).unwrap(); + assert_eq!(s.camera.unwrap().target, solidcraft_geom::Vec3::new(1.0, 2.0, 3.0)); + // Camera apply is an echo; these real display changes advance Session's view revision. + s.execute("browser.visibility", &json!({"items":[seam.body],"visible":false})).unwrap(); + s.execute("browser.visibility", &json!({"items":[seam.body],"visible":true})).unwrap(); + assert!(s.revision > revision); + assert!(s.continuity_target_points(&seam.body, seam.edge).is_some()); + s.execute("body.rename", &json!({"body":seam.body,"name":"Renamed"})).unwrap(); + assert!(!s.continuity_details()[0].current); + assert!(s.continuity_target_points(&seam.body, seam.edge).is_none()); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.continuity_details()[0].current, "restored matching geometry may be targeted again"); + let feature = s.doc.features[0].id; + s.execute("timeline.redefine", &json!({"feature":feature,"command":"solid.box","params":{"length":20,"width":10,"height":10}})).unwrap(); + assert!(!s.continuity_details()[0].current); + assert!(s.continuity_target_points(&seam.body, seam.edge).is_none()); + s.execute("edit.undo", &json!({})).unwrap(); + let restored_key = s.model.state().body(&seam.body).unwrap().geometry_key(); + assert_ne!( + restored_key, seam.details.geometry_key, + "feature redefinition undo produces a new evaluated generation, despite restoring dimensions" + ); + assert!(!s.continuity_details()[0].current); + assert!(s.continuity_target_points(&seam.body, seam.edge).is_none()); + s.execute("inspect.continuity",&json!({"body":seam.body,"edges":[{"index":seam.edge}],"samples":seam.details.samples, + "gap_tolerance_mm":seam.details.gap_tolerance_mm,"angle_tolerance_deg":seam.details.angle_tolerance_deg,"curvature_tolerance_per_mm":seam.details.curvature_tolerance_per_mm})).unwrap(); + assert!(s.continuity_details()[0].current, "a real fresh report validates the restored geometry generation"); + assert!(s.continuity_target_points(&seam.body, seam.edge).is_some()); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(!s.continuity_details()[0].current); + s.execute("inspect.continuity", &json!({"clear":true})).unwrap(); + assert!(s.continuity_details().is_empty()); + } + + #[test] + fn continuity_details_reject_changed_occurrence_placement_without_query_evaluation() { + let mut s = Session::default(); + let component = s.execute("component.create", &json!({"name":"Part","grounded":false})).unwrap(); + let occurrence = component["occurrence"].as_u64().unwrap(); + s.execute("solid.box", &json!({"length":10,"width":10,"height":10,"body_name":"PartBody"})).unwrap(); + s.execute("component.activate", &json!({"component":0})).unwrap(); + s.execute("inspect.continuity", &json!({"body":"PartBody","edges":[[5,0,0]],"samples":5})).unwrap(); + let record = s.continuity[0].clone(); + assert!(s.continuity_details()[0].current); + s.execute("occurrence.move", &json!({"occurrence":occurrence,"translate":[0,20,0],"capture":false})).unwrap(); + assert!(s.pending_moves.contains_key(&occurrence), "this test intentionally exercises a pending, revertible placement"); + let revision = s.revision; + assert!(!s.continuity_details()[0].current); + assert_eq!(s.revision, revision, "descriptor does not prepare a stale world or mutate it"); + let _placed = s.world_state(); + assert!(!s.continuity_details()[0].current, "a warmed moved occurrence is still different geometry placement"); + assert!(s.continuity_target_points(&record.body, record.edge).is_none()); + s.execute("component.revert_position", &json!({"occurrences":[occurrence]})).unwrap(); + assert!(!s.pending_moves.contains_key(&occurrence)); + let _restored = s.world_state(); + assert!(s.continuity_details()[0].current, "matching restored placement is validated by real geometry identity"); + } + #[test] fn hostile_continuity_params_are_refused() { let mut s = Session::default(); diff --git a/crates/engine/src/cmd/view_navigation.rs b/crates/engine/src/cmd/view_navigation.rs index 4e34336d17..86e387a051 100644 --- a/crates/engine/src/cmd/view_navigation.rs +++ b/crates/engine/src/cmd/view_navigation.rs @@ -35,6 +35,10 @@ const RENDER_SELECT: TypedContract = TypedContract::new(); const STATUS_ITEMS_CONTRACT: TypedContract = TypedContract::new(); pub static COMMANDS: &[CommandSpec] = &[ +CommandSpec::new("view.status_details", "Viewport Status Details", crate::checked!("view.status_details", status_details)) + .icon("list").noundo().doc_only() + .contract(&STATUS_ITEMS_CONTRACT).output(output_schema_for::) + .params("no parameters → current effect settings/detail capabilities and retained per-seam Continuity diagnostics, with current geometry validity; read-only, no evaluation"), CommandSpec::new("view.status_items", "Viewport Status Items", crate::checked!("view.status_items", status_items)) .icon("list").noundo().doc_only() .contract(&STATUS_ITEMS_CONTRACT).output(output_schema_for::) @@ -102,7 +106,7 @@ CommandSpec::new("view.navigate", "Navigate View", navigate) .icon("orbit") .noundo() .doc_only() - .params("camera: Camera; direction?: [x,y,z] in the camera's up-axis frame; or action: left|right|up|down|clockwise|counterclockwise|home|fit, degrees?: 0..180 (how far left|right|up|down turn; default 90); or motion: pan|orbit|zoom, delta?: [dx,dy] (pixels, each ±10000), free_orbit?: bool (orbit through poles, default false), viewport?: [width,height] (1..1000000, required for pan or pivot), pivot?: [x,y,z], factor?: 0.05..20 (required for zoom), anchor?: [x,y,z] → camera, fit, continuous?"), + .params("camera: Camera; direction?: [x,y,z] in the camera's up-axis frame; or action: left|right|up|down|clockwise|counterclockwise|home|fit, degrees?: 0..180 (how far left|right|up|down turn; default 90), fit?: bool (frame the whole model from the new direction as well, as home does; default false); or motion: pan|orbit|zoom, delta?: [dx,dy] (pixels, each ±10000), free_orbit?: bool (orbit through poles, default false), viewport?: [width,height] (1..1000000, required for pan or pivot), pivot?: [x,y,z], factor?: 0.05..20 (required for zoom), anchor?: [x,y,z] → camera, fit, continuous?"), CommandSpec::new("view.normal_to", "Normal to Selection", normal_to) .icon("camera") .noundo() @@ -186,6 +190,23 @@ fn normal_target(s: &Session) -> Option<(solidcraft_geom::Vec3, solidcraft_geom: }) } +fn status_details(s: &mut Session, p: &Value) -> Result { + let p = if p.is_null() { json!({}) } else { p.clone() }; + if !p.as_object().is_some_and(|object| object.is_empty()) { + return Err(bad("view.status_details", "parameters must be an empty object")); + } + let _: crate::schema::view_navigation::StatusItemsParams = serde_json::from_value(p).map_err(|e| bad("view.status_details", e.to_string()))?; + let items = + s.status_items().into_iter().map(|source| crate::status_details::StatusDetailItem { has_details: source.has_details(), source }).collect(); + serde_json::to_value(crate::status_details::StatusDetails { + document_id: s.document_id.clone(), + items, + continuity: s.continuity_details(), + param_warnings: Vec::new(), + }) + .map_err(|e| bad("view.status_details", e.to_string())) +} + fn normal_to(s: &mut Session, p: &Value) -> Result { let cmd = "view.normal_to"; let (point, normal) = normal_target(s).ok_or_else(|| bad(cmd, "select a planar face or plane"))?; @@ -415,10 +436,19 @@ fn navigate(_s: &mut Session, p: &Value) -> Result { { return Err(bad(cmd, "invalid camera")); } + // A turn may ask the window to frame the whole model from the new direction, as `home` does + // (the navigators' Fit preference, #10091). + let framed = match p.get("fit") { + None => false, + Some(value) => value.as_bool().ok_or_else(|| bad(cmd, "fit must be a boolean"))?, + }; if p.get("motion").is_some() { + if framed { + return Err(bad(cmd, "fit does not apply to a motion")); + } return continuous(cam, p); } - let mut fit = false; + let mut fit = framed; if let Some(value) = p.get("direction") { let dir = vec3(value) .filter(|v| v.is_finite() && v.len() <= 2.0) @@ -731,6 +761,34 @@ mod tests { } } + /// #10091: a turn asked to frame the model reports `fit`, as home does; a motion can't. + #[test] + fn a_turn_with_fit_asks_the_window_to_frame_the_model() { + let mut s = Session::default(); + let before = s.doc.to_json(); + let cam = Camera { distance: 100.0, ..Default::default() }; + for params in + [json!({"camera": cam, "direction": [0, 0, 1]}), json!({"camera": cam, "action": "right"}), json!({"camera": cam, "action": "clockwise"})] + { + let plain = s.execute("view.navigate", ¶ms).unwrap(); + assert_eq!(plain["fit"], false, "{params}"); + let mut framed = params.clone(); + framed["fit"] = json!(true); + let result = s.execute("view.navigate", &framed).unwrap(); + assert_eq!(result["fit"], true, "{params}"); + assert_eq!(result["camera"], plain["camera"], "fit asks for the framing, the turn is the same"); + framed["fit"] = json!(false); + assert_eq!(s.execute("view.navigate", &framed).unwrap(), plain); + } + assert_eq!(s.execute("view.navigate", &json!({"camera": cam, "action": "home", "fit": false})).unwrap()["fit"], true, "home frames anyway"); + for value in [json!(1), json!("yes"), Value::Null, json!([true])] { + assert!(s.execute("view.navigate", &json!({"camera": cam, "action": "right", "fit": value})).is_err(), "{value}"); + } + assert!(s.execute("view.navigate", &json!({"camera": cam, "motion": "zoom", "factor": 2.0, "fit": true})).is_err()); + assert!(s.execute("view.navigate", &json!({"camera": cam, "motion": "zoom", "factor": 2.0, "fit": false})).is_ok()); + assert_eq!(s.doc.to_json(), before); + } + #[test] fn continuous_navigation_moves_the_camera_without_editing_the_design() { let mut s = Session::default(); diff --git a/crates/engine/src/construct_tests.rs b/crates/engine/src/construct_tests.rs index 3fe9f2c49e..5fcfabdc18 100644 --- a/crates/engine/src/construct_tests.rs +++ b/crates/engine/src/construct_tests.rs @@ -585,3 +585,25 @@ fn offset_plane_through_a_point_follows_the_point() { // A distance and a point at once is refused. assert!(s.execute("construct.plane.offset", &json!({"base": "XY", "offset": 5, "through": {"vertex": [0, 0, 0]}})).is_err()); } + +/// Hiding the placement a component is shown at hides its construction geometry too, as it +/// hides its bodies and sketches. +#[test] +fn hiding_an_occurrence_hides_its_construction_geometry() { + let mut s = Session::default(); + run(&mut s, "component.create", json!({"name": "Part"})); + run(&mut s, "construct.plane.offset", json!({"base": "XY", "offset": "5 mm", "name": "Shelf"})); + run(&mut s, "construct.axis.two_planes", json!({"a": "XZ", "b": "YZ", "name": "Spine"})); + run(&mut s, "construct.point.three_planes", json!({"a": "XY", "b": "XZ", "c": "YZ", "name": "Pin"})); + let component = s.doc.features.iter().find(|f| f.name == "Shelf").map(|f| f.component).unwrap(); + assert_ne!(component, 0); + let path = s.doc.first_path(component); + run(&mut s, "component.activate", json!({"component": 0})); + for name in ["Shelf", "Spine", "Pin"] { + assert!(s.construction_visible(name), "{name}"); + } + run(&mut s, "browser.visibility", json!({"paths": [path], "visible": false})); + for name in ["Shelf", "Spine", "Pin"] { + assert!(!s.construction_visible(name), "{name} shows in a hidden placement"); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3b0380a78b..591677e215 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -33,6 +33,7 @@ pub mod slicer_files; mod slicer_icon; pub mod slicers; pub mod snap; +pub mod status_details; pub mod status_items; pub mod timing; pub mod view; @@ -175,6 +176,32 @@ pub enum Sel { }, } +impl Sel { + /// The body this item names, when it names one: the body itself, or the body a face, edge + /// or vertex belongs to. Session state that names a body has to follow its renames (#9737). + pub(crate) fn body_name(&self) -> Option<&str> { + match self { + Sel::Body { name } => Some(name), + Sel::Face { body, .. } | Sel::Edge { body, .. } | Sel::Vertex { body, .. } => Some(body), + _ => None, + } + } +} + +/// Whether the document differs from the one its file holds, as last worked out for this pair +/// of documents at this revision. The window asks every frame for every tab (the tab strip, the +/// autosave), and a large design compared field by field cost more than the rest of an idle +/// frame. Holding both documents keeps their contents fixed and their addresses unused by +/// another document while the answer stands: shared, a document can't be edited in place (an +/// edit copies it to a new address), and [`Session::doc_mut`] lets the answer go first so an +/// edit stays in place. +struct DirtyAnswer { + revision: u64, + doc: Arc, + saved: Arc, + differs: bool, +} + /// A body rename between two designs (see `Session::body_renames`). #[derive(Clone)] struct BodyRename { @@ -397,6 +424,9 @@ impl ComputedCommands { /// reports how it went and opens the answer itself. `Err`: it couldn't start. pub type EasyPrintUpload = Arc) -> std::result::Result<(), String> + Send + Sync>; +/// See [`Session::made_bodies`]. +type MadeBodies = std::collections::HashMap, Vec)>; + pub struct Session { pub doc: Arc, pub model: Model, @@ -420,6 +450,11 @@ pub struct Session { /// Opened read-only (`doc.open {read_only}`, the "open elsewhere" notice): saving to its own /// file is refused; Save As to another file is how its changes are kept. pub read_only: bool, + /// Whether the design's name is one its user chose for it (`file.new {name}`) rather than + /// one it borrowed — from a file's name, a sample's name, or a file that stored another + /// design's name. A borrowed name follows Save As to the new file's name (#4907); a name + /// the user chose is kept. + pub user_named: bool, /// One-shot display-only notification during doc.open, before authoritative evaluation. /// Interactive loaders may show it provisionally; it never becomes model state. pub open_preview: Option>, @@ -461,9 +496,18 @@ pub struct Session { /// (see [`Session::solve_joints`]). joints_settled: Option<(Arc, std::sync::Weak)>, occurrence_visibility_cache: std::sync::Mutex>, + /// The hidden joints and folders of the view, as sets: the browser asks about every joint + /// of the design on every frame (#3892), and an answer that walked the hidden lists made a + /// frame cost grow with the size of the design. + visibility_index_cache: std::sync::Mutex>, + /// The bodies each feature made, by name, with the state they were read from + /// ([`Session::pin_body_names`]). + made_bodies: MadeBodies, removed_later_cache: std::sync::Mutex>, occurrence_bodies_cache: std::sync::Mutex>, placement_index_cache: std::sync::Mutex>, + /// The last answer of [`Session::is_dirty`] on the document (see [`DirtyAnswer`]). + dirty_cache: std::sync::Mutex>, /// Pairs `inspect.clearance` measured, by the bodies' geometry where they stood (shared with /// scratch copies): a body that did not change or move is not measured again. clearance_cache: std::sync::Arc>, @@ -581,6 +625,8 @@ pub struct ContinuitySeam { pub points: Vec, /// The seam's worst verdict along its length. pub verdict: kernel::SeamVerdict, + /// Successful measurement settings and diagnostics, owned by this particular seam. + pub details: status_details::ContinuityDetail, } impl SurfaceAnalysis { @@ -647,6 +693,7 @@ impl Session { uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed), disk: None, read_only: false, + user_named: false, open_preview: None, log: Vec::new(), revision: 1, @@ -664,9 +711,12 @@ impl Session { world_cache: Default::default(), joints_settled: None, occurrence_visibility_cache: Default::default(), + visibility_index_cache: Default::default(), + made_bodies: MadeBodies::new(), removed_later_cache: Default::default(), occurrence_bodies_cache: Default::default(), placement_index_cache: Default::default(), + dirty_cache: Default::default(), clearance_cache: Default::default(), body_renames: Vec::new(), contact_meshes: Default::default(), @@ -732,9 +782,34 @@ impl Session { /// The design has changes not in its file: in the document, or in what is hidden. pub fn is_dirty(&self) -> bool { - (!Arc::ptr_eq(&self.doc, &self.saved) && *self.doc != *self.saved) || self.visibility != self.saved_visibility + self.document_differs() || self.visibility != self.saved_visibility } + + /// The document is not the one its file holds: compared once per pair of documents and + /// revision ([`DirtyAnswer`]). + fn document_differs(&self) -> bool { + if Arc::ptr_eq(&self.doc, &self.saved) { + return false; + } + let Ok(mut answer) = self.dirty_cache.lock() else { return *self.doc != *self.saved }; + let same = |a: &DirtyAnswer| a.revision == self.revision && Arc::ptr_eq(&a.doc, &self.doc) && Arc::ptr_eq(&a.saved, &self.saved); + if let Some(a) = answer.as_ref().filter(|a| same(a)) { + return a.differs; + } + let differs = *self.doc != *self.saved; + *answer = Some(DirtyAnswer { revision: self.revision, doc: self.doc.clone(), saved: self.saved.clone(), differs }); + differs + } + + /// Let the unsaved-changes answer go, with the documents it holds. + fn forget_dirty_answer(&self) { + if let Ok(mut answer) = self.dirty_cache.lock() { + *answer = None; + } + } + pub fn mark_saved(&mut self) { + self.forget_dirty_answer(); self.saved = self.doc.clone(); self.saved_visibility = self.visibility.clone(); } @@ -840,6 +915,8 @@ impl Session { if let Ok(mut index) = self.placement_index_cache.lock() { *index = None; } + self.forget_dirty_answer(); + self.model.forget_fingerprints(); Arc::make_mut(&mut self.doc) } @@ -940,6 +1017,22 @@ impl Session { } } + /// The model body a name means: one of the model's own bodies as it is named, else the body + /// a placed copy shows (`KB (K:2)`, the name `inspect.measure` lists), as `appearance.assign` + /// takes it (#9740). `None` when neither names it. + pub(crate) fn own_body_name(&self, name: &str) -> Option { + if self.model.state().body(name).is_some() { + return Some(name.to_string()); + } + self.body_frames().iter().find(|(world, ..)| world == name).map(|(_, own, _)| own.clone()) + } + + /// A component by its id or name, or by the name of one of its placements (`K:2`); never the + /// root. + pub(crate) fn component_or_placement(&self, key: &str) -> Option { + self.doc.find_component(key).or_else(|| self.doc.occurrences.iter().find(|o| o.name == key).map(|o| o.component)).filter(|id| *id != 0) + } + pub fn refresh(&mut self) { if self.model.evaluate_shared(&self.doc) { #[cfg(test)] @@ -1004,11 +1097,21 @@ impl Session { /// [`Session::refresh`] after the design changed from `previous`; a change that only renamed /// a body or a feature (or undid or redid such a rename) renames it in the model instead, /// which keeps every result and mesh (#3044). - fn refresh_from(&mut self, previous: &Arc) { - self.follow_plane_renames(previous); - self.follow_axis_point_renames(previous); + fn refresh_from(&mut self, previous: &Arc, visibility_restored: bool) { let rename = self.body_rename(previous); - self.remap_hidden_bodies(previous, &self.model.state(), rename.as_ref().map(|(old, new, _)| (old.as_str(), new.as_str()))); + // Undo/redo restored visibility in the target document's names already. Remapping + // those names again can transfer a deleted body's stale hide to the restored body. + if !visibility_restored { + self.follow_plane_renames(previous); + self.follow_axis_point_renames(previous); + self.remap_hidden_bodies(previous, &self.model.state(), rename.as_ref().map(|(old, new, _)| (old.as_str(), new.as_str()))); + // Locked bodies follow explicit renames and outside edits too (#9737). + if let Some((old, new, _)) = &rename { + for b in self.visibility.locked_bodies.iter_mut().filter(|b| **b == *old) { + *b = new.clone(); + } + } + } if let Some((old, new, names_only)) = rename && names_only && self.model.rename_body(&self.doc, &old, &new) @@ -1042,12 +1145,34 @@ impl Session { } let mut pins: Vec<(u64, Vec)> = Vec::new(); let mut reserve: Vec = Vec::new(); - for f in self.doc.features.iter().filter(|f| !matches!(f.kind, solidcraft_doc::FeatureKind::FrameMember { .. })) { - let Some(r) = self.model.result(f.id).filter(|r| r.error.is_none() && !r.skipped && !r.stale) else { continue }; - let made: Vec<&str> = r.state.bodies.iter().filter(|b| b.feature == f.id).map(|b| b.name.as_str()).collect(); + // The names of the bodies each feature made, kept with the state they were read from: + // every command pins, and reading them again searched every result's bodies (features × + // bodies per command). A state is the same while it is the same allocation (the weak + // reference keeps that address from being reused), and its bodies never change in place. + let mut known = std::mem::take(&mut self.made_bodies); + let mut made_bodies = MadeBodies::with_capacity(known.len()); + for (i, f) in self.doc.features.iter().enumerate() { + if matches!(f.kind, solidcraft_doc::FeatureKind::FrameMember { .. }) { + continue; + } + // Results are in timeline order: the feature's own index first, the search only when not. + let r = self.model.results.get(i).filter(|r| r.id == f.id).or_else(|| self.model.result(f.id)); + let Some(r) = r.filter(|r| r.error.is_none() && !r.skipped && !r.stale) else { continue }; + let made = match known.remove(&f.id).filter(|(state, _)| std::sync::Weak::as_ptr(state) == Arc::as_ptr(&r.state)) { + Some(entry) => made_bodies.entry(f.id).or_insert(entry), + None => made_bodies.entry(f.id).or_insert_with(|| { + let names = r.state.bodies.iter().filter(|b| b.feature == f.id).map(|b| b.name.clone()).collect(); + (Arc::downgrade(&r.state), names) + }), + }; + let made: Vec<&str> = made.1.iter().map(String::as_str).collect(); reserve.extend( made.iter().filter(|n| solidcraft_doc::is_counted_body_name(n) && !self.doc.reserved_body_names.contains(**n)).map(|n| n.to_string()), ); + // Nothing to pin when every slot the bodies take already holds a name. + if made.len() <= f.body_names.len() && f.body_names.iter().take(made.len()).all(|slot| !slot.trim().is_empty()) { + continue; + } let mut names = f.body_names.clone(); names.resize(names.len().max(made.len()), String::new()); for (slot, name) in names.iter_mut().zip(&made) { @@ -1059,9 +1184,13 @@ impl Session { pins.push((f.id, names)); } } + self.made_bodies = made_bodies; if pins.is_empty() && reserve.is_empty() { return; } + // The fingerprints that stand: taken before the pins change the design. + let renamed: Vec = pins.iter().map(|(id, _)| *id).collect(); + let keep = self.model.pinned_prefix(&self.doc, &renamed); let doc = self.doc_mut(); for (id, names) in pins { if let Some(f) = doc.feature_mut(id) { @@ -1070,7 +1199,7 @@ impl Session { } doc.reserved_body_names.extend(reserve); // The pinned names are the ones the bodies have: no result changes, so none is evaluated. - self.model.refingerprint_shared(&self.doc); + self.model.refingerprint_shared(&self.doc, keep); } /// A hidden construction plane stays hidden when the design renames it, or undo or redo @@ -1204,6 +1333,10 @@ impl Session { /// as before. Otherwise store each sketch whose values changed as solved, so the next edit /// continues from it (see `Model::adopt_solved_sketches`). pub(crate) fn settle_value_edit(&mut self, solved_before: &SketchValues) -> Result<()> { + // The solved geometry each sketch had before the edit, for the shrink/fling checks + // after the refresh below (the same checks `settle` runs on an edit's own solve). + let solved_geometry: std::collections::BTreeMap = + self.model.results.iter().filter_map(|r| r.state.sketch(r.id).map(|k| (r.id, k.sketch.clone()))).collect(); // Quantity changes alter topology, so store the regenerated instances in the same // transaction as the parameter edit. Later picks and undo must see those same ids. let mut updated: Vec<_> = self @@ -1232,16 +1365,28 @@ impl Session { self.refresh(); for r in &self.model.results { let (Some(before), Some(k)) = (solved_before.get(&r.id), r.state.sketch(r.id)) else { continue }; - if !k.report.keeps_orientation() && own_values(&k.sketch) != *before { + if own_values(&k.sketch) == *before { + continue; + } + if !k.report.keeps_orientation() { let why = k.report.failure(|f| format!("its constraints would conflict ({f})")); return Err(EngineError::Other(format!("sketch `{}`: {why}; the values were not changed", r.name))); } + // The value changed this sketch and it still solves: refuse it the way `settle` + // refuses an edit's solve when the curve was squeezed away or points thrown far, + // so a zero (or wild) value is never stored as a sketch that can't be solved back. + if let Some(before_sketch) = solved_geometry.get(&r.id) + && let Some(why) = cmd::collapse_reason(before_sketch, &k.sketch) + { + return Err(EngineError::Other(format!("sketch `{}`: {why}; the values were not changed", r.name))); + } } // Checked first: storing makes the document unique (a copy when the undo step shares it). if self.model.edited_sketches(&self.doc).next().is_some() { if let Ok(mut index) = self.placement_index_cache.lock() { *index = None; } + self.forget_dirty_answer(); self.model.adopt_solved_sketches(Arc::make_mut(&mut self.doc)); } Ok(()) @@ -1373,7 +1518,7 @@ impl Session { } None if render_only => self.refresh_render(&previous_doc), None if display_only => self.refresh_display(), - None => self.refresh_from(&previous_doc), + None => self.refresh_from(&previous_doc, matches!(spec.id, "edit.undo" | "edit.redo")), } if pin { self.pin_body_names(); @@ -1459,8 +1604,13 @@ impl Session { /// Go back to checkpoint `id` as if nothing since had happened (the undo history too). It /// and later checkpoints are used up. pub fn restore_checkpoint(&mut self, id: u64) -> Result<()> { - let i = self.checkpoints.iter().position(|c| c.id == id).ok_or_else(|| EngineError::Other(format!("no checkpoint {id}")))?; - let Some(c) = self.checkpoints.drain(i..).next() else { return Err(EngineError::Other(format!("no checkpoint {id}"))) }; + let missing = || { + EngineError::Other(format!( + "no checkpoint {id} (used up by restoring or forgetting it or an earlier one, or dropped: only the last {MAX_CHECKPOINTS} are kept)" + )) + }; + let i = self.checkpoints.iter().position(|c| c.id == id).ok_or_else(missing)?; + let Some(c) = self.checkpoints.drain(i..).next() else { return Err(missing()) }; let before = Snapshot { label: String::new(), doc: self.doc.clone(), @@ -1508,6 +1658,7 @@ impl Session { uid: self.uid, disk: self.disk, read_only: self.read_only, + user_named: self.user_named, open_preview: None, saved: self.saved.clone(), log: Vec::new(), @@ -1525,10 +1676,15 @@ impl Session { pending_moves: self.pending_moves.clone(), world_cache: std::sync::Mutex::new(self.world_cache.lock().ok().and_then(|cache| cache.clone())), joints_settled: self.joints_settled.clone(), + // Read again by the copy: handing it these references measured more peak memory on a + // large design (the rover, evaluated ahead) for no change in what it pins. + made_bodies: MadeBodies::new(), occurrence_visibility_cache: Default::default(), + visibility_index_cache: Default::default(), removed_later_cache: Default::default(), occurrence_bodies_cache: Default::default(), placement_index_cache: Default::default(), + dirty_cache: Default::default(), clearance_cache: self.clearance_cache.clone(), body_renames: self.body_renames.clone(), contact_meshes: self.contact_meshes.clone(), diff --git a/crates/engine/src/occurrences.rs b/crates/engine/src/occurrences.rs index 8d6ac6cc79..816c567c40 100644 --- a/crates/engine/src/occurrences.rs +++ b/crates/engine/src/occurrences.rs @@ -1,5 +1,5 @@ //! Exact placement identity and session view/authoring state. -use crate::{EngineError, Result, Sel, Session}; +use crate::{EngineError, Result, Sel, Session, Visibility}; use solidcraft_doc::{FeatureKind, IDENTITY, Mat, PlaneRef, mat_mul}; #[derive(Clone, Debug)] @@ -32,6 +32,139 @@ pub(crate) struct VisibilityCache { bodies: std::collections::BTreeSet, } +/// The hidden joints and folders of the view, as sets: the browser asks about every joint +/// and every folder of the design on every frame, and an answer that walked the hidden +/// lists made a frame cost grow with the size of the design (#3892). The sets are built +/// when the view changes and are kept until it changes again. +pub(crate) struct VisibilityIndex { + /// The revision of the design the sets were built for. + revision: u64, + /// Where the hidden joints were when the sets were built: the list is replaced (the + /// whole view is replaced) or written in place (a joint is hidden or shown), and this + /// says whether the list the sets were built from is still the list of the view. + joints_at: ListId, + /// The hidden joint ids. + joint_ids: std::collections::HashSet, + /// Where the hidden folders were when the sets were built. + folders_at: ListId, + /// The hidden folder keys. + folder_keys: std::collections::HashSet, + /// The hidden joints and folders, as they were when the sets were built: they are + /// compared with these to tell a change of the view from a change of the design alone + /// (the design changes far more often than the view does). + joints: Vec, + folders: Vec, +} + +/// Where a list of hidden items was: the address of its buffer and how many items it held. +/// A list that is replaced gets a buffer of its own (the old one is still alive while the +/// new one is allocated, so the addresses differ), and a list that is written in place keeps +/// its buffer; the length then says how much of it is written. +#[derive(Clone, Copy, PartialEq, Eq)] +struct ListId { + address: usize, + length: usize, +} + +impl ListId { + fn of(list: &[T]) -> ListId { + ListId { address: list.as_ptr() as usize, length: list.len() } + } +} + +impl VisibilityIndex { + /// The sets of the hidden joints and folders of a view. + fn new(revision: u64, v: &Visibility) -> VisibilityIndex { + let joint_ids: std::collections::HashSet = v.hidden_joints.iter().copied().collect(); + let folder_keys: std::collections::HashSet = v.hidden_folders.iter().cloned().collect(); + visibility_work::built(joint_ids.len() as u64 + folder_keys.len() as u64); + VisibilityIndex { + revision, + joints_at: ListId::of(&v.hidden_joints), + joint_ids, + folders_at: ListId::of(&v.hidden_folders), + folder_keys, + joints: v.hidden_joints.clone(), + folders: v.hidden_folders.clone(), + } + } + + /// Do the sets still answer for this design and this view? The revision says the design + /// changed; the lists say the view changed under it (the view is replaced outside the + /// engine too, without a revision of its own). + /// + /// The first and last entries of each list are part of the answer: a buffer that was freed + /// and handed out again holds another list, and the entries say so. + fn matches(&self, revision: u64, v: &Visibility) -> bool { + self.revision == revision + && self.joints_at == ListId::of(&v.hidden_joints) + && self.folders_at == ListId::of(&v.hidden_folders) + && self.joints.first() == v.hidden_joints.first() + && self.joints.last() == v.hidden_joints.last() + && self.folders.first() == v.hidden_folders.first() + && self.folders.last() == v.hidden_folders.last() + } + + /// Are the hidden joints and folders the ones the sets were built from? A design that + /// changed without touching the view leaves the sets alone. + fn same_lists(&self, v: &Visibility) -> bool { + self.joints == v.hidden_joints && self.folders == v.hidden_folders + } + + /// The lists are the ones the sets were built from, at another revision. + fn restamp(&mut self, revision: u64, v: &Visibility) { + self.revision = revision; + self.joints_at = ListId::of(&v.hidden_joints); + self.folders_at = ListId::of(&v.hidden_folders); + } +} + +/// How many entries of the hidden lists a visibility answer had to walk, and how many the +/// index was built from. The browser asks about every joint of the design on every frame, so +/// the answer must not cost more as the design grows (#3892); the counters make that cost +/// visible to a test. +pub(crate) mod visibility_work { + #[cfg(test)] + use std::cell::Cell; + + // The counter of the thread that asks: the tests run beside each other, and each counts + // its own work. + #[cfg(test)] + thread_local! { + static WALKED: Cell = const { Cell::new(0) }; + static BUILT: Cell = const { Cell::new(0) }; + } + + /// The entries the answers of this thread walked since the last [`take`]. + #[cfg(test)] + pub(crate) fn take() -> u64 { + WALKED.with(|walked| walked.replace(0)) + } + + /// Count the entries an answer walked. + pub(crate) fn add(walked: u64) { + #[cfg(test)] + WALKED.with(|count| count.set(count.get() + walked)); + #[cfg(not(test))] + let _ = walked; + } + + /// Count the entries the index was built from: the index is built once per change of the + /// view, not once per answer, so this work is apart from the answers' own. + pub(crate) fn built(built: u64) { + #[cfg(test)] + BUILT.with(|count| count.set(count.get() + built)); + #[cfg(not(test))] + let _ = built; + } + + /// The entries the index was built from since the last [`built_take`]. + #[cfg(test)] + pub(crate) fn built_take() -> u64 { + BUILT.with(|count| count.replace(0)) + } +} + /// [`Session::occurrence_bodies`] answers for one document, evaluated model and set of pending /// moves. The document and model are kept alive with the answers, so neither a new document nor /// a later allocation at the same address can reuse them. @@ -132,8 +265,10 @@ pub(crate) struct RemovedLaterCache { } impl Session { - /// Hidden names follow the same body and exact occurrence path through a rename. Undo - /// history restores visibility separately. Never parse display labels: + /// Hidden names and the bodies the selection names follow the same body and exact + /// occurrence path through a rename (#9737: a selected body stays selected under its new + /// name, so Delete hits the body the user actually selected, never one that later takes + /// the old name). Undo history restores visibility separately. Never parse display labels: /// ambiguous labels and names containing slashes use the document's canonical naming. pub(crate) fn remap_hidden_bodies( &mut self, @@ -141,7 +276,8 @@ impl Session { state: &solidcraft_doc::ModelState, rename: Option<(&str, &str)>, ) { - if self.visibility.hidden_bodies.is_empty() { + let selected: std::collections::BTreeSet<&str> = self.selection.iter().filter_map(|s| s.body_name()).collect(); + if self.visibility.hidden_bodies.is_empty() && selected.is_empty() { return; } if rename.is_none() { @@ -161,19 +297,39 @@ impl Session { } let hidden: std::collections::BTreeSet<_> = self.visibility.hidden_bodies.iter().map(String::as_str).collect(); let mut names = std::collections::BTreeMap::new(); + let mut destinations = std::collections::BTreeSet::new(); let (before, after) = (solidcraft_doc::WorldNames::new(previous, state), solidcraft_doc::WorldNames::new(&self.doc, &renamed)); for (component, path, _) in previous.placements() { for body in state.bodies.iter().filter(|body| previous.model_body_component(body) == component) { let old = before.name(&body.name, component, &path); - if hidden.contains(old.as_str()) { - let source = rename.filter(|(old, _)| *old == body.name).map_or(body.name.as_str(), |(_, new)| new); - names.insert(old, after.name(source, component, &path)); + let source = rename.filter(|(old, _)| *old == body.name).map_or(body.name.as_str(), |(_, new)| new); + let new = after.name(source, component, &path); + if old != new { + destinations.insert(new.clone()); + } + if hidden.contains(old.as_str()) || selected.contains(old.as_str()) { + names.insert(old, new); } } } - for hidden in &mut self.visibility.hidden_bodies { + self.visibility.hidden_bodies.retain_mut(|hidden| { if let Some(new) = names.get(hidden) { hidden.clone_from(new); + true + } else { + // A deleted body's hidden name can be reused (#10163). Only a current + // body's hide follows into that name; a stale entry must not hide it. + !destinations.contains(hidden) + } + }); + for sel in &mut self.selection { + let name = match sel { + Sel::Body { name } => name, + Sel::Face { body, .. } | Sel::Edge { body, .. } | Sel::Vertex { body, .. } => body, + _ => continue, + }; + if let Some(new) = names.get(name.as_str()) { + name.clone_from(new); } } } @@ -549,10 +705,36 @@ impl Session { Ok(count) } + /// The hidden joints and folders of the view, as sets: the browser asks about every joint + /// of the design on every frame, and an answer that walked the hidden lists made a frame + /// cost grow with the size of the design (#3892). The sets are built when the view changes + /// and are kept until it changes again. + fn visibility_index(&self) -> Option>> { + let mut cache = self.visibility_index_cache.lock().ok()?; + let v = &self.visibility; + if cache.as_ref().is_none_or(|c| !c.matches(self.revision, v)) { + // The design or the view changed. The sets are rebuilt only when the hidden lists + // themselves changed: a change of the design alone leaves them alone. + if cache.as_ref().is_some_and(|c| c.same_lists(v)) { + if let Some(c) = cache.as_mut() { + c.restamp(self.revision, v); + } + } else { + *cache = Some(VisibilityIndex::new(self.revision, v)); + } + } + Some(cache) + } + /// Whether a joint's glyph is drawn (`browser.visibility`). Hiding a joint only hides its /// drawing: it still constrains and drives the parts. /// Is the joint shown itself (its own eye; the Joints folder may still hide it)? pub fn joint_visible(&self, id: u64) -> bool { + if let Some(cache) = self.visibility_index() { + return cache.as_ref().is_none_or(|c| !c.joint_ids.contains(&id)); + } + // The lock is poisoned: answer from the list itself. + visibility_work::add(self.visibility.hidden_joints.len() as u64); !self.visibility.hidden_joints.contains(&id) } @@ -569,6 +751,11 @@ impl Session { /// Is a browser folder or group hidden itself (by tree key, see `Visibility::hidden_folders`)? pub fn folder_hidden(&self, key: &str) -> bool { + if let Some(cache) = self.visibility_index() { + return cache.as_ref().is_none_or(|c| c.folder_keys.contains(key)); + } + // The lock is poisoned: answer from the list itself. + visibility_work::add(self.visibility.hidden_folders.len() as u64); self.visibility.hidden_folders.iter().any(|k| k == key) } @@ -590,29 +777,43 @@ impl Session { }) } + /// The placement a sketch or construction feature is drawn at: the active one while its + /// component is being edited, else the component's first (empty for the design's own). + fn feature_path(&self, component: u64) -> Vec { + match &self.active_occurrence_path { + _ if component == 0 => Vec::new(), + Some(p) if self.active_component == component => p.clone(), + _ => self.doc.first_path(component), + } + } + + /// Is the placement a sketch or construction feature is drawn at shown? + pub fn feature_placement_visible(&self, id: u64) -> bool { + if self.visibility.hidden_occurrences.is_empty() { + return true; + } + let component = self.doc.feature(id).map_or(0, |f| f.component); + component == 0 || self.occurrence_visible(&self.feature_path(component)) + } + /// Is a sketch or construction feature hidden by its folder or group, at the placement it - /// is drawn at (the active one while its component is being edited, else the first)? + /// is drawn at? pub fn feature_folder_hidden(&self, id: u64, folder: &str) -> bool { if self.visibility.hidden_folders.is_empty() { return false; } let component = self.doc.feature(id).map_or(0, |f| f.component); - let path = match &self.active_occurrence_path { - _ if component == 0 => Vec::new(), - Some(p) if self.active_component == component => p.clone(), - _ => self.doc.first_path(component), - }; - self.item_folder_hidden(&Self::folder_scope(&path), component, folder, &id.to_string()) + self.item_folder_hidden(&Self::folder_scope(&self.feature_path(component)), component, folder, &id.to_string()) } /// Is construction geometry or an origin item (by name) shown: not hidden itself, nor by - /// its folder or a group it is in? + /// its folder or a group it is in, nor by a hidden placement of its component? pub fn construction_visible(&self, name: &str) -> bool { if self.visibility.hidden_origin.iter().any(|h| h == name) { return false; } match self.doc.features.iter().find(|f| f.name == name) { - Some(f) => !self.feature_folder_hidden(f.id, "construction"), + Some(f) => self.feature_placement_visible(f.id) && !self.feature_folder_hidden(f.id, "construction"), None => true, } } @@ -731,6 +932,15 @@ impl Session { } let selection = std::mem::take(&mut self.selection); self.selection = selection.into_iter().filter(|s| !matches!(s, Sel::Occurrence {path} if self.occurrence_placement(path).is_err())).collect(); + // A selected body that is gone cannot be deleted, measured or shown: drop it, so a + // later body taking its name is never deleted in its place (#9737). + if self.selection.iter().any(|s| s.body_name().is_some()) { + let world = self.world_state(); + self.selection.retain(|s| match s.body_name() { + Some(name) => world.body(name).is_some(), + None => true, + }); + } let hidden = std::mem::take(&mut self.visibility.hidden_occurrences); self.visibility.hidden_occurrences = hidden.into_iter().filter(|p| self.occurrence_placement(p).is_ok()).collect(); } diff --git a/crates/engine/src/params.rs b/crates/engine/src/params.rs index 10e6343a15..4fcfc97f61 100644 --- a/crates/engine/src/params.rs +++ b/crates/engine/src/params.rs @@ -146,21 +146,22 @@ pub fn string_list(cmd: &str, p: &Value, k: &str) -> Result> { /// named by its position as in [`string_list`]. The commands that take feature keys read them /// here, because their list is not a list of names (#6651). pub fn name_or_id_list(cmd: &str, p: &Value, k: &str) -> Result> { + Ok(name_or_id_values(cmd, p, k)?.iter().map(|v| v.as_str().map(str::to_owned).unwrap_or_else(|| v.to_string())).collect()) +} + +/// Keep the JSON types when a command distinguishes a numeric name from a numeric id. +pub(crate) fn name_or_id_values<'a>(cmd: &str, p: &'a Value, k: &str) -> Result<&'a [Value]> { match p.get(k) { - None | Some(Value::Null) => Ok(Vec::new()), - Some(Value::String(s)) => Ok(vec![s.clone()]), + None | Some(Value::Null) => Ok(&[]), + Some(v @ Value::String(_)) => Ok(std::slice::from_ref(v)), Some(Value::Array(a)) => { if a.len() > MAX_STRING_LIST { return Err(bad(cmd, format!("`{k}` must list at most {MAX_STRING_LIST} keys"))); } - a.iter() - .enumerate() - .map(|(i, v)| match v { - Value::String(s) => Ok(s.clone()), - Value::Number(n) if n.is_u64() => Ok(n.to_string()), - other => Err(bad(cmd, format!("`{k}` must list names or ids; the entry {} is {}", i + 1, json_brief(other)))), - }) - .collect() + if let Some((i, v)) = a.iter().enumerate().find(|(_, v)| !v.is_string() && !v.is_u64()) { + return Err(bad(cmd, format!("`{k}` must list names or ids; the entry {} is {}", i + 1, json_brief(v)))); + } + Ok(a) } Some(v) => Err(bad(cmd, format!("`{k}` must be a list of names or ids; it is {}", json_brief(v)))), } diff --git a/crates/engine/src/schema/joints.rs b/crates/engine/src/schema/joints.rs new file mode 100644 index 0000000000..81c0afd61e --- /dev/null +++ b/crates/engine/src/schema/joints.rs @@ -0,0 +1,113 @@ +//! Contracts and result types for the commands in `cmd/joints.rs`. + +use std::borrow::Cow; + +use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema}; +use serde::de::Error as _; +use serde::{Deserialize, Deserializer, Serialize}; +use serde_json::Value; + +use super::types::{AngleExpr, IdOrName, LengthExpr, present}; + +/// A joint origin as joint.create takes it: a joint origin's name, or an object with the snap +/// (`occurrence`, then `face`, `circle`, `edge` or `point`, or a `frame`). Kept as given, for the +/// handler, which reads it as it reads joint.create's sides. +#[derive(Clone, Debug, PartialEq)] +pub struct JointOriginParam(pub Value); + +impl<'de> Deserialize<'de> for JointOriginParam { + fn deserialize>(d: D) -> Result { + match Value::deserialize(d)? { + v @ (Value::String(_) | Value::Object(_)) => Ok(JointOriginParam(v)), + _ => Err(D::Error::custom("must be a joint origin's name or an object")), + } + } +} + +impl JsonSchema for JointOriginParam { + fn schema_name() -> Cow<'static, str> { + "JointOrigin".into() + } + fn json_schema(_: &mut SchemaGenerator) -> Schema { + json_schema!({ + "description": "A joint origin, as joint.create takes a side: a joint origin's name, or an object with occurrence and face, circle, edge or point (world coordinates, or a face's name), with z?, x?, end?, on?, corner?, or with a frame {origin, z, x?}.", + "anyOf": [ + {"type": "string", "minLength": 1}, + {"type": "object"} + ] + }) + } +} + +/// Edit a joint in place. The sides `a` and `b` take a joint origin as joint.create does; a side +/// left out keeps its place, and the two must stay on different occurrences. The joint re-solves +/// and keeps its limits, its drive and the expressions it follows; a relation's values start +/// again where its parts are when its sides, type, offset, angle or flip change. +#[derive(Debug, Deserialize, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct EditJointParams { + /// The joint, by id or name. + pub joint: IdOrName, + /// Side a: a joint origin, as joint.create takes it. Default: a keeps its place. + #[serde(default, deserialize_with = "present")] + pub a: Option, + /// Side b: a joint origin, as joint.create takes it. Default: b keeps its place. + #[serde(default, deserialize_with = "present")] + pub b: Option, + /// The joint's type, as joint.create takes it (rigid, revolute, slider, ...). + #[serde(default, rename = "type", deserialize_with = "present")] + pub kind: Option, + /// Whether b's z lies along a's instead of against it, as joint.create's `flip`. + #[serde(default, deserialize_with = "present")] + pub flip: Option, + /// The distance from a's frame to b's along z: a length, or an expression the joint follows. + #[serde(default, deserialize_with = "present")] + pub offset: Option, + /// The turn about that distance: an angle, or an expression the joint follows. + #[serde(default, deserialize_with = "present")] + pub angle: Option, + /// Suppress the joint (true) or bring it back (false). + #[serde(default, deserialize_with = "present")] + pub suppressed: Option, + /// The joint's new name. + #[serde(default, deserialize_with = "present")] + pub name: Option, + /// Leave out the degrees of freedom and the fixed occurrences in the reply. + #[serde(default, deserialize_with = "present")] + pub quiet: Option, +} + +/// A joint the solve reports: its id, name and why. +#[derive(Debug, Serialize, JsonSchema)] +pub struct JointProblem { + pub joint: u64, + pub name: String, + pub why: String, +} + +/// The degrees of freedom one occurrence keeps (6 free, 0 fixed). +#[derive(Debug, Serialize, JsonSchema)] +pub struct OccurrenceDof { + pub occurrence: u64, + pub dof: usize, +} + +/// What `joint.edit` returns: the joints that disagree with the solved placement, and those the +/// solve could not apply. Unless `quiet`, also each occurrence's degrees of freedom and the +/// occurrences fixed in place. +#[derive(Debug, Serialize, JsonSchema)] +pub struct EditJointResult { + pub conflicts: Vec, + pub errors: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dof: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fixed: Option>, + /// 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, +} + +#[cfg(test)] +#[path = "joints_tests.rs"] +mod tests; diff --git a/crates/engine/src/schema/joints_tests.rs b/crates/engine/src/schema/joints_tests.rs new file mode 100644 index 0000000000..5fc406abcf --- /dev/null +++ b/crates/engine/src/schema/joints_tests.rs @@ -0,0 +1,27 @@ +use serde_json::json; + +use super::{EditJointParams, EditJointResult}; +use crate::Session; +use crate::schema::tests::{assert_keys, conforms}; +use crate::schema::{input_schema_for, output_schema_for}; + +#[test] +fn schemas_and_the_key_check_agree() { + assert_keys(input_schema_for::(), &["a", "angle", "b", "flip", "joint", "name", "offset", "quiet", "suppressed", "type"]); +} + +#[test] +fn replies_match_the_output_schema() { + let schema = output_schema_for::(); + let schema = schema.as_value(); + let mut s = Session::default(); + s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10})).unwrap_or_else(|e| panic!("{e}")); + s.execute("component.create", &json!({"name": "B", "grounded": false})).unwrap_or_else(|e| panic!("{e}")); + s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0]})).unwrap_or_else(|e| panic!("{e}")); + s.execute("joint.create", &json!({"type": "rigid", "name": "Stack", "a": {"face": [5, 5, 10]}, "b": {"face": [25, 5, 0]}})) + .unwrap_or_else(|e| panic!("{e}")); + let full = s.execute("joint.edit", &json!({"joint": "Stack", "flip": true})).unwrap_or_else(|e| panic!("{e}")); + assert!(conforms(schema, schema, &full), "{full} doesn't match {schema}"); + let quiet = s.execute("joint.edit", &json!({"joint": "Stack", "quiet": true})).unwrap_or_else(|e| panic!("{e}")); + assert!(quiet.get("dof").is_none() && conforms(schema, schema, &quiet), "{quiet}"); +} diff --git a/crates/engine/src/schema/mass_properties.rs b/crates/engine/src/schema/mass_properties.rs new file mode 100644 index 0000000000..4775ab50e0 --- /dev/null +++ b/crates/engine/src/schema/mass_properties.rs @@ -0,0 +1,114 @@ +//! Contracts and result types for the commands in `cmd/mass_properties.rs`. +//! +//! The reply types are also the Rust API a dynamics solver reads (`Session::mass_properties`): +//! units are kg, mm and kg·mm² throughout. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// Mass, centre of mass and inertia tensor of every occurrence of the design and of every rigid +/// group (occurrences joined by rigid joints, which move as one body), from each body's material +/// density. Read-only. +#[derive(Debug, Deserialize, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct AssemblyMassParams {} + +/// A placed coordinate frame in world coordinates (mm): its origin and its unit axes. +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct MassFrame { + pub origin: [f64; 3], + pub x: [f64; 3], + pub y: [f64; 3], + pub z: [f64; 3], +} + +/// Mass, centre of mass and inertia tensor of one rigid body (or of several taken as one). +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct RigidMass { + /// Mass in kg. + pub mass_kg: f64, + /// Centre of mass in world coordinates, mm. + pub center_of_mass_mm: [f64; 3], + /// Centre of mass in the frame's coordinates, mm. + pub center_of_mass_local_mm: [f64; 3], + /// Inertia tensor about the centre of mass, world axes, kg·mm², row by row: the moments on + /// the diagonal (Ixx = ∫ (y² + z²) dm …), the negated products of inertia off it + /// (−∫ x y dm …), as URDF and most dynamics libraries take it. `null` when a closed body's + /// mesh does not close (a warning names it). + pub inertia_world_kg_mm2: Option<[[f64; 3]; 3]>, + /// The same tensor in the frame's axes, kg·mm². `null` as above, or when the frame is + /// scaled (its axes are not a rotation). + pub inertia_local_kg_mm2: Option<[[f64; 3]; 3]>, +} + +/// One body of an occurrence. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct BodyMassEntry { + pub name: String, + /// Its physical material (the default material when none is assigned). + pub material: String, + pub density_g_cm3: f64, + /// A closed solid: an open body (a surface) has no volume and adds no mass. + pub closed: bool, + /// Volume in mm³ and mass in kg, for a closed body only. + pub volume_mm3: Option, + pub mass_kg: Option, +} + +/// One occurrence (0: the design's own bodies, which never move). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct OccurrenceMass { + pub occurrence: u64, + pub name: String, + /// The component it places. + pub component: u64, + /// Occurrence ids from the top of the assembly down (empty for 0). + pub path: Vec, + /// Index of its rigid group in `groups`. + pub group: usize, + /// Grounded, or the design itself. + pub fixed: bool, + /// The occurrence's frame (its component's origin and axes as placed). + pub frame: MassFrame, + pub bodies: Vec, + /// Bodies left out because they are not closed solids. + pub open_bodies_skipped: usize, + /// Its own bodies' totals in its frame; `null` when it holds no closed body. + pub mass: Option, +} + +/// Occurrences that move as one body. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct RigidGroupMass { + /// Its index in `groups`. + pub group: usize, + /// The occurrences it holds, ascending (0: the design's own bodies). + pub occurrences: Vec, + /// Holds the design itself or a grounded occurrence: it does not move. + pub fixed: bool, + /// The occurrence whose frame `frame` is: 0 (world) for a fixed group holding the design, + /// else the group's lowest occurrence id. + pub frame_occurrence: u64, + pub frame: MassFrame, + /// Totals of all its occurrences in `frame` (parallel-axis theorem); `null` when massless. + pub mass: Option, +} + +/// What `assembly.mass_properties` returns. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct AssemblyMassResult { + /// Every occurrence, the design's own bodies (0) first, then by occurrence id. + pub occurrences: Vec, + /// The rigid groups, in order of their lowest occurrence id. + pub groups: Vec, + /// Approximations made (open bodies, repeated subassemblies, scaled frames). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub warnings: Vec, + /// 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, +} + +#[cfg(test)] +#[path = "mass_properties_tests.rs"] +mod tests; diff --git a/crates/engine/src/schema/mass_properties_tests.rs b/crates/engine/src/schema/mass_properties_tests.rs new file mode 100644 index 0000000000..6f42ba9653 --- /dev/null +++ b/crates/engine/src/schema/mass_properties_tests.rs @@ -0,0 +1,28 @@ +use serde_json::json; + +use super::{AssemblyMassParams, AssemblyMassResult}; +use crate::Session; +use crate::schema::tests::{assert_keys, conforms}; +use crate::schema::{ParamCheck, input_schema_for, output_schema_for}; + +#[test] +fn schemas_and_the_key_check_agree() { + assert_keys(input_schema_for::(), &[]); +} + +#[test] +fn replies_match_the_output_schema() { + let schema = output_schema_for::(); + let schema = schema.as_value(); + let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() }; + let empty = s.execute("assembly.mass_properties", &json!({})).unwrap_or_else(|e| panic!("{e}")); + assert!(conforms(schema, schema, &empty), "{empty} doesn't match {schema}"); + assert!(empty.get("param_warnings").is_none(), "{empty}"); + s.execute("component.create", &json!({"name": "Block"})).unwrap_or_else(|e| panic!("{e}")); + s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10})).unwrap_or_else(|e| panic!("{e}")); + let r = s.execute("assembly.mass_properties", &json!({"unknown_extra": 1})).unwrap_or_else(|e| panic!("{e}")); + assert!(r.get("param_warnings").is_some(), "{r}"); + assert!(conforms(schema, schema, &r), "{r} doesn't match {schema}"); + s.param_check = ParamCheck::Strict; + assert!(s.execute("assembly.mass_properties", &json!({"unknown_extra": 1})).is_err()); +} diff --git a/crates/engine/src/schema/mod.rs b/crates/engine/src/schema/mod.rs index 04a13fbe40..4896d91e16 100644 --- a/crates/engine/src/schema/mod.rs +++ b/crates/engine/src/schema/mod.rs @@ -59,14 +59,19 @@ pub mod construct; pub mod edit; pub mod features; pub mod inspect; +pub mod joints; +pub mod mass_properties; pub mod print_face; pub mod render; pub mod render_lights; pub mod render_ui; +pub mod section; pub mod sheet; pub mod sketch_constraints; pub mod sketch_create; pub mod sketch_equation; +pub mod surface_analysis; +pub mod ui_prefs; pub mod view_navigation; pub use check::{Contract, Issue, ParamCheck, TypedContract, input_schema_for, issues, output_schema_for, run_checked, run_checked_as}; diff --git a/crates/engine/src/schema/section.rs b/crates/engine/src/schema/section.rs new file mode 100644 index 0000000000..a99e66b8b3 --- /dev/null +++ b/crates/engine/src/schema/section.rs @@ -0,0 +1,43 @@ +//! Section clipping contract. Explicit planes use numerical millimetre coordinates. +use super::{AxisDir, LengthExpr, Point3, present}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(untagged)] +pub enum Plane { + Name(String), + Id(u64), + Explicit { origin: Option, normal: AxisDir }, +} +#[derive(Debug, Deserialize, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct SectionParams { + /// Required unless clearing. XY/XZ/YZ, construction name/id, or {origin?,normal}. + #[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")] + #[schemars(with = "Plane")] + pub plane: Option, + /// Signed distance along the original plane normal, in design units or an expression. + pub offset: Option, + /// World coordinate on a named plane's normal axis; mutually exclusive with offset. + pub at: Option, + pub flip: Option, + pub hatch: Option, + pub clear: Option, +} +#[derive(Serialize, JsonSchema)] +pub struct Section { + pub origin: [f64; 3], + pub normal: [f64; 3], + pub hatch: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub axis: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub at: Option, +} +#[derive(Serialize, JsonSchema)] +pub struct SectionResult { + pub section: Option
, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub param_warnings: Vec, +} diff --git a/crates/engine/src/schema/sketch_equation.rs b/crates/engine/src/schema/sketch_equation.rs index 66b1b18dd0..ee3b04f720 100644 --- a/crates/engine/src/schema/sketch_equation.rs +++ b/crates/engine/src/schema/sketch_equation.rs @@ -68,6 +68,33 @@ impl JsonSchema for Tolerance { } } +/// What the curve's variable is in its formulas. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct VariableUnitParam(pub solidcraft_sketch::VariableUnit); + +impl<'de> Deserialize<'de> for VariableUnitParam { + fn deserialize>(d: D) -> Result { + Value::deserialize(d)? + .as_str() + .and_then(solidcraft_sketch::VariableUnit::parse) + .map(VariableUnitParam) + .ok_or_else(|| D::Error::custom("must be \"number\" or \"range\"")) + } +} + +impl JsonSchema for VariableUnitParam { + fn schema_name() -> Cow<'static, str> { + "EquationVariableUnit".into() + } + fn json_schema(_: &mut SchemaGenerator) -> Schema { + json_schema!({ + "description": "What the variable is in the formulas. \"number\": a plain number. \"range\": a length, as the range is; start and end must then both be lengths, or one a length and the other a plain number, which is in the design's unit. Default \"number\".", + "type": "string", + "enum": ["number", "range"] + }) + } +} + /// An equation curve's id. #[derive(Clone, Debug, PartialEq)] pub struct CurveId(pub String); @@ -113,6 +140,9 @@ pub struct EquationCurveParams { /// How far (mm) the curve may stray from its formulas. Default 0.01. #[serde(default)] pub tolerance: Option, + /// What the variable is in the formulas. Default "number". + #[serde(default)] + pub variable_unit: Option, /// Make it construction geometry. Default false. #[serde(default)] pub construction: Option, @@ -147,6 +177,9 @@ pub struct EquationEditParams { /// How far (mm) the curve may stray from its formulas. Kept when left out. #[serde(default)] pub tolerance: Option, + /// What the variable is in the formulas. Kept when left out. + #[serde(default)] + pub variable_unit: Option, /// The sketch (default: the one being edited). #[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")] #[schemars(with = "SketchRef")] diff --git a/crates/engine/src/schema/sketch_equation_tests.rs b/crates/engine/src/schema/sketch_equation_tests.rs index ca3ff39689..3d76691252 100644 --- a/crates/engine/src/schema/sketch_equation_tests.rs +++ b/crates/engine/src/schema/sketch_equation_tests.rs @@ -7,8 +7,11 @@ use crate::schema::{ParamCheck, input_schema_for, output_schema_for}; #[test] fn schemas_and_the_key_check_agree() { - assert_keys(input_schema_for::(), &["construction", "end", "explicit", "sketch", "start", "tolerance", "x", "y"]); - assert_keys(input_schema_for::(), &["curve", "end", "explicit", "sketch", "start", "tolerance", "x", "y"]); + assert_keys( + input_schema_for::(), + &["construction", "end", "explicit", "sketch", "start", "tolerance", "variable_unit", "x", "y"], + ); + assert_keys(input_schema_for::(), &["curve", "end", "explicit", "sketch", "start", "tolerance", "variable_unit", "x", "y"]); } #[test] @@ -21,6 +24,9 @@ fn strict_keeps_todays_errors() { ("sketch.equation_curve", json!({"x": "t", "y": "t", "start": 0, "end": 1, "explicit": 1})), ("sketch.equation_curve", json!({"x": "t", "y": "t", "start": 0, "end": 1e12})), ("sketch.equation_curve", json!({"y": "t", "start": 0, "end": 1})), + ("sketch.equation_curve", json!({"x": "t", "y": "t", "start": 0, "end": 1, "variable_unit": "mm"})), + ("sketch.equation_curve", json!({"x": "t", "y": "t", "start": 0, "end": 1, "variable_unit": true})), + ("sketch.equation_curve", json!({"x": "t", "y": "t", "start": 0, "end": 1, "variable_unit": "range"})), ("sketch.equation_curve.edit", json!({"curve": 7})), ("sketch.equation_curve.edit", json!({"curve": "nope", "y": "t"})), ]); @@ -43,4 +49,11 @@ fn replies_match_their_schemas() { let edited = edited.as_value(); let r = run(&mut s, "sketch.equation_curve.edit", json!({"curve": id, "end": "6 * pi"})); assert!(conforms(edited, edited, &r), "{r} doesn't match {edited}"); + // The variable in its range's unit, then back: both accepted in strict, nothing warned. + s.param_check = ParamCheck::Strict; + let r = run(&mut s, "sketch.equation_curve", json!({"x": "t", "y": "t / 2", "start": 0, "end": "10 mm", "variable_unit": "range"})); + assert!(conforms(created, created, &r), "{r} doesn't match {created}"); + let id = r["curve"].as_str().unwrap_or_else(|| panic!("{r}")).to_string(); + let r = run(&mut s, "sketch.equation_curve.edit", json!({"curve": id, "variable_unit": "number"})); + assert!(conforms(edited, edited, &r), "{r} doesn't match {edited}"); } diff --git a/crates/engine/src/schema/surface_analysis.rs b/crates/engine/src/schema/surface_analysis.rs new file mode 100644 index 0000000000..72c37800ea --- /dev/null +++ b/crates/engine/src/schema/surface_analysis.rs @@ -0,0 +1,163 @@ +//! Contracts for view-only surface analyses; the handlers validate domain ranges. +use super::{AxisDir, Point3, present}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct ZebraParams { + /// Stripe frequency across the view, including fractional values. Default 12; 1…200. + pub stripes: Option, + pub clear: Option, +} +#[derive(Debug, Deserialize, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct DraftParams { + /// Nonzero pull direction. Default +Z; normalised by the handler. + #[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")] + #[schemars(with = "AxisDir")] + pub pull: Option, + /// Threshold in degrees, 0 inclusive to 90 exclusive. Default 1. + pub angle: Option, + pub clear: Option, +} +#[derive(Debug, Deserialize, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct EnvironmentParams { + pub clear: Option, +} +#[derive(Debug, Deserialize, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct AccessibilityParams { + /// Nonzero direction the tool comes from. Default +Z. + #[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")] + #[schemars(with = "AxisDir")] + pub direction: Option, + pub clear: Option, +} +#[derive(Debug, Deserialize, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct CurvatureParams { + /// Radius in mm shown red, greater than 1e-6 and less than 1e9. + /// Default a tenth of the largest model diagonal, or 10 mm for an empty model. + pub radius: Option, + pub clear: Option, +} +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(untagged)] +pub enum EdgePick { + Index { index: usize }, + Point(Point3), +} +#[derive(Debug, Deserialize, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct ContinuityParams { + /// Body name; otherwise visible bodies. At most 64 bodies in one call. + pub body: Option, + /// At most 512 edge indices or points. Empty/absent measures all seams. + #[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")] + #[schemars(with = "Vec")] + pub edges: Option>, + /// Integer stations per edge, 3…257. Default 33. + #[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")] + #[schemars(with = "f64")] + pub samples: Option, + /// Positional tolerance in mm, 0…1e9 exclusive. Default .001. + #[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")] + #[schemars(with = "f64")] + pub gap_tolerance_mm: Option, + /// Normal angle tolerance in degrees. Default 1. + #[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")] + #[schemars(with = "f64")] + pub angle_tolerance_deg: Option, + /// Curvature tolerance in inverse mm. Default .01. + #[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")] + #[schemars(with = "f64")] + pub curvature_tolerance_per_mm: Option, + /// Preserve other measured seams, replacing only matching body/edge records. + pub add: Option, + pub clear: Option, +} + +#[derive(Serialize, JsonSchema)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum Analysis { + Zebra { stripes: f64 }, + Draft { pull: [f64; 3], angle: f64 }, + Curvature { radius: f64 }, + Environment, + Access { dir: [f64; 3] }, +} +#[derive(Serialize, JsonSchema)] +pub struct AnalysisResult { + pub analysis: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub param_warnings: Vec, +} +#[derive(Serialize, JsonSchema)] +pub struct Metric { + pub value: f64, + pub at: [f64; 3], + pub edge_fraction: f64, +} +#[derive(Serialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum Verdict { + Smooth, + CurvatureJump, + Sharp, + Gap, +} +#[derive(Serialize, JsonSchema)] +pub struct EdgeReport { + pub edge: usize, + pub length_mm: f64, + pub samples: usize, + pub gap_mm: Metric, + pub normal_angle_deg: Metric, + pub curvature_jump_per_mm: Metric, + pub verdict: Verdict, +} +#[derive(Serialize, JsonSchema)] +pub struct BodyReport { + pub body: String, + pub edges: Vec, + pub skipped: usize, + pub unmeasured: usize, +} +#[derive(Serialize, JsonSchema)] +pub struct Tolerances { + pub gap_mm: f64, + pub angle_deg: f64, + pub curvature_per_mm: f64, +} +#[derive(Serialize, JsonSchema)] +pub struct Summary { + pub edges: usize, + pub smooth: usize, + pub curvature_jump: usize, + pub sharp: usize, + pub gap: usize, + pub skipped: usize, + pub unmeasured: usize, + pub worst: Option, +} +#[derive(Serialize, JsonSchema)] +#[serde(untagged)] +pub enum ContinuityResult { + Cleared { + analysis: String, + cleared: bool, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + param_warnings: Vec, + }, + Measured { + analysis: String, + samples: usize, + tolerances: Tolerances, + bodies: Vec, + summary: Summary, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + param_warnings: Vec, + }, +} diff --git a/crates/engine/src/schema/tests.rs b/crates/engine/src/schema/tests.rs index 20492bf729..fa2a0d3b5d 100644 --- a/crates/engine/src/schema/tests.rs +++ b/crates/engine/src/schema/tests.rs @@ -378,6 +378,93 @@ fn a_level_is_read_back_from_its_name() { } } +#[test] +fn ui_prefs_theme_options_input_schema_covers_updates_and_resets() { + use super::ui_prefs::PrefsParams; + let schema = input_schema_for::(); + let schema = schema.as_value(); + assert!(schema["properties"]["theme_options"].is_object()); + for value in [ + json!({}), + json!({"theme_options":{}}), + json!({"theme_options":{"a":{}}}), + json!({"theme_options":{"a":{"enabled":true,"amount":3.5,"style":"cool","reset":null}},"theme":"a"}), + ] { + assert!(serde_json::from_value::(value.clone()).is_ok(), "{value}"); + assert!(conforms(schema, schema, &value), "{value}"); + } + for value in [ + json!({"theme_options":null}), + json!({"theme_options":[]}), + json!({"theme_options":{"a":true}}), + json!({"theme_options":{"a":{"enabled":{}}}}), + json!({"theme_options":{"a":{"amount":[]}}}), + ] { + assert!(serde_json::from_value::(value.clone()).is_err(), "{value}"); + } +} + +#[test] +fn ui_prefs_theme_options_output_schema_matches_serialized_reply() { + use super::ui_prefs::*; + let options = ThemeOptionsReply { + theme_option_defs: vec![ + ThemeOptionDef { + id: "enabled".into(), + kind: ThemeOptionKind::Bool { default: false }, + label: "Enabled".into(), + help: String::new(), + parent: None, + advanced: false, + section: None, + standard: false, + available: true, + reason: None, + }, + ThemeOptionDef { + id: "blur".into(), + kind: ThemeOptionKind::Range { default: 0.0, min: 0.0, max: 100.0, step: 1.0, unit: Some("px".into()) }, + label: "blur".into(), + help: String::new(), + parent: None, + advanced: false, + section: None, + standard: true, + available: false, + reason: Some("not implemented".into()), + }, + ThemeOptionDef { + id: "style".into(), + kind: ThemeOptionKind::Choice { default: "warm".into(), values: vec![ThemeChoice { id: "warm".into(), label: "Warm".into() }] }, + label: "Style".into(), + help: String::new(), + parent: Some("enabled".into()), + advanced: true, + section: Some("Details".into()), + standard: false, + available: true, + reason: None, + }, + ], + theme_option_values: [ + ("enabled".into(), ThemeOptionValue::Bool(true)), + ("blur".into(), ThemeOptionValue::Num(0.0)), + ("style".into(), ThemeOptionValue::Choice("warm".into())), + ] + .into(), + theme_option_notices: vec![ThemeOptionNotice { id: "removed".into(), kind: ThemeOptionNoticeKind::UnknownOption }], + }; + let reply = PrefsReply { options, settings: [("theme".into(), json!("a")), ("theme_options".into(), json!({"a":{"enabled":true}}))].into() }; + let value = serde_json::to_value(&reply).unwrap(); + let schema = output_schema_for::(); + let schema = schema.as_value(); + for field in ["theme_option_defs", "theme_option_values", "theme_option_notices"] { + assert!(schema["properties"][field].is_object(), "{schema}"); + } + assert!(conforms(schema, schema, &value), "{schema}\n{value}"); + assert_eq!(serde_json::to_value(serde_json::from_value::(value.clone()).unwrap()).unwrap(), value); +} + /// Whether `v` conforms to `node` of the schema `root`, for the keywords these schemas use /// apart from `pattern`: `type`, `properties`, `required`, `additionalProperties: false`, /// `items`, `minItems`, `maxItems`, `uniqueItems`, `enum`, `minLength`, `maxLength`, `minimum`, diff --git a/crates/engine/src/schema/ui_prefs.rs b/crates/engine/src/schema/ui_prefs.rs new file mode 100644 index 0000000000..c992dd8553 --- /dev/null +++ b/crates/engine/src/schema/ui_prefs.rs @@ -0,0 +1,89 @@ +//! Theme-option contracts for the UI-owned `ui.prefs` control method. +//! Other preferences remain owned and validated by the UI preference table. + +use std::collections::BTreeMap; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, JsonSchema)] +#[serde(untagged)] +pub enum ThemeOptionValue { + Bool(bool), + Choice(String), + Num(f32), +} + +/// Null resets one option; an empty inner map resets that theme. +pub type ThemeOptionsUpdate = BTreeMap>>; + +#[derive(Debug, Default, Deserialize, Serialize, JsonSchema)] +pub struct PrefsParams { + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub theme_options: ThemeOptionsUpdate, + #[serde(flatten)] + pub settings: BTreeMap, +} + +#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] +pub struct ThemeChoice { + pub id: String, + pub label: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum ThemeOptionKind { + Bool { default: bool }, + Choice { default: String, values: Vec }, + Range { default: f32, min: f32, max: f32, step: f32, unit: Option }, +} + +#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] +pub struct ThemeOptionDef { + pub id: String, + #[serde(flatten)] + pub kind: ThemeOptionKind, + pub label: String, + pub help: String, + pub parent: Option, + pub advanced: bool, + pub section: Option, + pub standard: bool, + pub available: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ThemeOptionNoticeKind { + UnknownOption, + WrongType, + UnknownChoice, + NonFinite, + OutOfRange, +} + +#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] +pub struct ThemeOptionNotice { + pub id: String, + pub kind: ThemeOptionNoticeKind, +} + +#[derive(Debug, Default, Deserialize, Serialize, JsonSchema)] +pub struct ThemeOptionsReply { + pub theme_option_defs: Vec, + pub theme_option_values: BTreeMap, + pub theme_option_notices: Vec, +} + +/// Complete control reply: other preferences and dialog state share the outer object. +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +pub struct PrefsReply { + #[serde(flatten)] + pub options: ThemeOptionsReply, + #[serde(flatten)] + pub settings: BTreeMap, +} diff --git a/crates/engine/src/schema/unconverted.txt b/crates/engine/src/schema/unconverted.txt index bc165ff770..d1852f9b3d 100644 --- a/crates/engine/src/schema/unconverted.txt +++ b/crates/engine/src/schema/unconverted.txt @@ -95,29 +95,21 @@ frame.member frame.profile.insert frame.profile.list frame.profile.properties -inspect.accessibility inspect.center_of_mass inspect.clearance inspect.compare -inspect.continuity inspect.curvature_comb -inspect.curvature_map -inspect.draft -inspect.environment_map inspect.interference inspect.isocurve inspect.measure inspect.minimum_radius inspect.parameter_sweep -inspect.section inspect.silhouette -inspect.zebra joint.as_built joint.cam joint.create joint.delete joint.drive -joint.edit joint.gear joint.inspect joint.limits diff --git a/crates/engine/src/schema/view_navigation_tests.rs b/crates/engine/src/schema/view_navigation_tests.rs index 7ef13a61cc..1abe2043f8 100644 --- a/crates/engine/src/schema/view_navigation_tests.rs +++ b/crates/engine/src/schema/view_navigation_tests.rs @@ -10,6 +10,62 @@ fn schemas_and_the_key_check_agree() { assert_keys(input_schema_for::(), &["enabled"]); } +#[test] +fn status_details_has_checked_pure_query_and_current_typed_diagnostics() { + let schema = output_schema_for::(); + let root = schema.as_value(); + let mut s = Session::default(); + let spec = crate::find_command("view.status_details").unwrap(); + assert!(!spec.undoable && !spec.reads_model); + assert!(spec.contract.is_some() && spec.output.is_some()); + s.execute("solid.box", &json!({"length":10,"width":10,"height":10})).unwrap(); + s.execute("inspect.continuity", &json!({"edges":[[5,0,0]],"samples":5})).unwrap(); + for mode in [ParamCheck::Off, ParamCheck::Warn, ParamCheck::Strict] { + s.param_check = mode; + let before = (s.revision, s.doc.to_json(), s.undo.len(), s.continuity.clone()); + for params in [json!({}), Value::Null] { + let reply = s.execute("view.status_details", ¶ms).unwrap(); + assert!(conforms(root, root, &reply), "{reply} does not match {root}"); + assert_eq!(reply["continuity"][0]["samples"], 5); + assert_eq!(reply["continuity"][0]["current"], true); + assert_eq!(reply["continuity"][0]["verdict"], "sharp"); + assert_eq!(reply["items"][0]["source"]["command"], "inspect.continuity"); + assert_eq!(reply["items"][0]["has_details"], true); + } + for params in [json!({"clear":true}), json!([]), json!(false)] { + assert!(s.execute("view.status_details", ¶ms).is_err()); + } + assert_eq!((s.revision, s.doc.to_json(), s.undo.len(), s.continuity.clone()), before); + } +} + +#[test] +fn surface_and_section_contracts_match_successful_existing_reply_shapes() { + let mut s = Session { param_check: ParamCheck::Strict, ..Default::default() }; + s.execute("solid.box", &json!({"length":10,"width":10,"height":10})).unwrap(); + for (command, params) in [ + ("inspect.zebra", json!({"stripes":14.5})), + ("inspect.draft", json!({"pull":[-2,3,-4],"angle":12.75})), + ("inspect.environment_map", json!({})), + ("inspect.accessibility", json!({"direction":[0,0,-1]})), + ("inspect.curvature_map", json!({"radius":25.4})), + ("inspect.section", json!({"plane":{"origin":[4,5,6],"normal":[-1,2,-3]},"offset":"-2 mm","flip":true,"hatch":false})), + ( + "inspect.continuity", + json!({"edges":[[5,0,0]],"samples":5,"gap_tolerance_mm":0.002,"angle_tolerance_deg":2,"curvature_tolerance_per_mm":0.03}), + ), + ] { + let description = s.execute("engine.describe", &json!({"command":command})).unwrap(); + assert_eq!(description["input"]["additionalProperties"], false); + for params in [params, json!({"clear":true})] { + let reply = s.execute(command, ¶ms).unwrap(); + let root = &description["output"]; + assert!(conforms(root, root, &reply), "{command}: {reply} does not match {root}"); + } + assert!(s.execute(command, &json!({"unknown_setting":true})).is_err(), "checked registration for {command}"); + } +} + #[test] fn status_items_has_strict_empty_input_and_a_typed_output_schema() { use crate::status_items::StatusItems; diff --git a/crates/engine/src/status_details.rs b/crates/engine/src/status_details.rs new file mode 100644 index 0000000000..349b6c2142 --- /dev/null +++ b/crates/engine/src/status_details.rs @@ -0,0 +1,93 @@ +//! Retained diagnostics for the existing Continuity effect. Queries never evaluate geometry. +use schemars::JsonSchema; +use serde::Serialize; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ContinuityVerdict { + Smooth, + CurvatureJump, + Sharp, + Gap, +} +impl From for ContinuityVerdict { + fn from(value: crate::kernel::SeamVerdict) -> Self { + match value { + crate::kernel::SeamVerdict::Smooth => Self::Smooth, + crate::kernel::SeamVerdict::CurvatureJump => Self::CurvatureJump, + crate::kernel::SeamVerdict::Sharp => Self::Sharp, + crate::kernel::SeamVerdict::Gap => Self::Gap, + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, JsonSchema)] +pub struct ContinuityDetail { + pub body: String, + pub edge: usize, + pub samples: usize, + pub gap_tolerance_mm: f64, + pub angle_tolerance_deg: f64, + pub curvature_tolerance_per_mm: f64, + pub gap_mm: f64, + pub normal_angle_deg: f64, + pub curvature_jump_per_mm: f64, + pub length_mm: f64, + pub verdict: ContinuityVerdict, + pub gap_at: [f64; 3], + pub gap_fraction: f64, + pub normal_angle_at: [f64; 3], + pub normal_angle_fraction: f64, + pub curvature_jump_at: [f64; 3], + pub curvature_jump_fraction: f64, + pub skipped: usize, + pub unmeasured: usize, + /// True only while the exact evaluated geometry and occurrence placement still exist. + pub current: bool, + #[serde(skip)] + pub(crate) geometry_key: Option, + #[serde(skip)] + pub(crate) feature: u64, +} + +#[derive(Serialize, JsonSchema)] +pub struct StatusDetailItem { + pub source: crate::status_items::StatusItem, + pub has_details: bool, +} + +#[derive(Serialize, JsonSchema)] +pub struct StatusDetails { + pub document_id: String, + pub items: Vec, + pub continuity: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub param_warnings: Vec, +} + +impl crate::Session { + /// Cheap current-state lookup. A pending model evaluation or cold/stale assembly world + /// is conservatively unavailable, even if the old state's geometry keys still match. + pub fn continuity_details(&self) -> Vec { + let world = if self.model.is_stale() { None } else { self.cached_world_state() }; + self.continuity + .iter() + .map(|seam| { + let mut detail = seam.details.clone(); + detail.verdict = seam.verdict.into(); + detail.current = world.as_ref().and_then(|world| world.body(&seam.body)).is_some_and(|body| { + detail.geometry_key.is_some() && body.geometry_key() == detail.geometry_key && body.feature == detail.feature + }); + detail + }) + .collect() + } + + /// Validate again at activation; a button's earlier descriptor is never authority. + pub fn continuity_target_points(&self, body: &str, edge: usize) -> Option<&[solidcraft_geom::Vec3]> { + if !self.continuity_details().iter().any(|row| row.body == body && row.edge == edge && row.current) { + return None; + } + self.continuity.iter().find(|seam| seam.body == body && seam.edge == edge).map(|seam| seam.points.as_slice()) + } +} diff --git a/crates/engine/src/status_items.rs b/crates/engine/src/status_items.rs index a132303ec2..5dff8791ce 100644 --- a/crates/engine/src/status_items.rs +++ b/crates/engine/src/status_items.rs @@ -90,6 +90,14 @@ pub struct StatusItems { pub items: Vec, } +impl StatusItem { + /// Capability independent of a window's presentation preferences. `has_key` retains + /// the existing read-only legend contract; live details also include settings/findings. + pub fn has_details(&self) -> bool { + !matches!(self.setting, StatusSetting::Environment) + } +} + impl Session { /// Whether shading and Continuity share one slot (the default). This is session /// configuration, never design data; SDK clients may choose the legacy independent policy. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index a3c374c8cd..aa1b4f2501 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1930,6 +1930,24 @@ fn checkpoint_restores_with_a_full_undo_history() { assert!(s.execute("edit.restore_checkpoint", &json!({"checkpoint": id})).is_err()); } +/// Regression (#9746): a checkpoint restored already, or one of more than the 16 kept, was +/// refused with only "no checkpoint N", so a script that restores twice could not tell why. +#[test] +fn a_used_up_or_dropped_checkpoint_says_why() { + let mut s = Session::default(); + let id = run(&mut s, "edit.checkpoint", json!({}))["checkpoint"].as_u64().unwrap(); + run(&mut s, "edit.restore_checkpoint", json!({"checkpoint": id})); + let used = s.execute("edit.restore_checkpoint", &json!({"checkpoint": id})).unwrap_err().to_string(); + assert!(used.contains("used up") && used.contains("16"), "{used}"); + let first = run(&mut s, "edit.checkpoint", json!({}))["checkpoint"].as_u64().unwrap(); + for _ in 0..MAX_CHECKPOINTS { + run(&mut s, "edit.checkpoint", json!({})); + } + let dropped = s.execute("edit.restore_checkpoint", &json!({"checkpoint": first})).unwrap_err().to_string(); + assert!(dropped.contains("16"), "{dropped}"); + run(&mut s, "edit.restore_checkpoint", json!({"checkpoint": first + 1})); +} + /// Regression (#3242): `file.new` replaced the session and its checkpoints with it, so a /// batch that failed after `file.new` could not go back to the design it began in. #[test] @@ -3419,6 +3437,48 @@ fn computed_import_preserves_file_and_earlier_undo_history() { assert!(s.model.state().bodies.is_empty()); } +/// The unsaved-changes answer is worked out once per pair of documents and revision, and stays +/// right through every way the design changes: an edit in place (`doc_mut`, which stays in +/// place), one that bypasses it, a new document equal to the saved one, Save, a change of what +/// is hidden, and undo back to the saved design. Each answer is checked against the plain +/// comparison it stands for. +#[test] +fn the_unsaved_changes_answer_follows_every_change() { + let plain = |s: &Session| (!std::sync::Arc::ptr_eq(&s.doc, &s.saved) && *s.doc != *s.saved) || s.visibility != s.saved_visibility; + let check = |s: &Session, want: bool, why: &str| { + for _ in 0..2 { + assert_eq!(s.is_dirty(), want, "{why}"); + assert_eq!(plain(s), want, "{why}: the plain comparison"); + } + }; + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10})); + s.mark_saved(); + check(&s, false, "saved"); + // An edit in place: the cached answer holds the document, yet the edit is not a copy. + s.doc = std::sync::Arc::new((*s.doc).clone()); + check(&s, false, "an equal copy of the saved design"); + let at = std::sync::Arc::as_ptr(&s.doc); + s.doc_mut().name = "Renamed".into(); + assert_eq!(std::sync::Arc::as_ptr(&s.doc), at, "doc_mut edits in place"); + check(&s, true, "renamed in place, same revision"); + s.doc_mut().name = s.saved.name.clone(); + check(&s, false, "named back in place"); + // An edit that goes around doc_mut while the answer holds the document copies it. + std::sync::Arc::make_mut(&mut s.doc).name = "Elsewhere".into(); + check(&s, true, "edited past doc_mut"); + s.mark_saved(); + check(&s, false, "saved again"); + run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [30, 0, 0]})); + check(&s, true, "a command"); + run(&mut s, "edit.undo", json!({})); + check(&s, false, "undone back to the saved design"); + s.visibility.hidden_bodies.push("Body1".into()); + check(&s, true, "a body hidden"); + s.visibility.hidden_bodies.clear(); + check(&s, false, "shown again"); +} + /// Commands evaluated ahead (off the window thread) and then run for real take what was /// evaluated: on a design whose states are past the shared cache's budget the second run /// evaluated everything again, so an off-thread command cost twice its time. diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index 611151ba6d..a9cb64d6d9 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -223,6 +223,183 @@ pub fn grid_step(half_height: f64) -> (f64, f64) { (minor, minor * 5.0) } +/// How wide the cross-fade between two grid levels is, in powers of ten of the view's height +/// (#9570): about 19% of zoom either side of the switch. The switches are at least +/// `log10(2)` apart, so two cross-fades never overlap. +const GRID_LEVEL_BLEND: f64 = 0.15; + +/// One level of the drawn grid: its spacing and how strongly it is drawn. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GridLevel { + pub minor: f64, + pub major: f64, + /// 0..1, the level's share of the cross-fade; the levels drawn at one zoom add up to 1. The + /// lines only this level has may be drawn weaker than its share ([`grid_lines`]). + pub weight: f32, +} + +/// Where the view is in a cross-fade between two grid levels (#9570). +#[derive(Clone, Copy, Debug)] +struct GridBlend { + /// The finer and the coarser level's minor steps. + lo: f64, + hi: f64, + /// 0..1 through the cross-fade; the switch of [`grid_step`] is at 0.5. + s: f64, + /// Whether the coarser step is a whole multiple of the finer one (1 to 2, 5 to 10), so that + /// every coarser line is also a finer one. From 2 to 5 it is not. + nested: bool, +} + +impl GridBlend { + /// The coarser level's share of the cross-fade: a smoothstep through it. + fn share_hi(&self) -> f32 { + smoothstep(self.s) as f32 + } + + /// How strongly the lines only one of the two levels has are drawn (finer, coarser). + /// + /// Nested levels cross-fade as their shares do. Levels that do not nest (2 and 5: lines at + /// 0, 2, 4, 5, 6, 8, 10 mm) look restless drawn together, so the finer level's own lines fade + /// out over the first half of the cross-fade and the coarser level's own lines fade in over + /// the second: the two are never seen at once. The lines both share (every 10 mm, the finer + /// level's major lines) stay at full strength throughout, so the grid never goes blank. + fn own(&self) -> (f32, f32) { + if self.nested { + let hi = self.share_hi(); + (1.0 - hi, hi) + } else { + (smoothstep(1.0 - 2.0 * self.s) as f32, smoothstep(2.0 * self.s - 1.0) as f32) + } + } +} + +fn smoothstep(x: f64) -> f64 { + let x = x.clamp(0.0, 1.0); + x * x * (3.0 - 2.0 * x) +} + +/// The cross-fade the view of the given half height is in, if any (#9570). +fn grid_blend(half_height: f64) -> Option { + let (minor, _) = grid_step(half_height); + let raw = half_height / 10.0; + // grid_step's floor: no finer level is ever drawn, so there is nothing to fade to. + if !raw.is_finite() || !minor.is_finite() || raw <= 1e-3 || minor <= 0.0 { + return None; + } + let p = 10f64.powf((minor.log10() + 1e-9).floor()); + let mantissa = (minor / p).round(); + // The neighbouring steps of the 1, 2, 5 sequence. + let (finer, coarser) = if mantissa < 1.5 { + (minor * 0.5, minor * 2.0) + } else if mantissa < 3.5 { + (minor * 0.5, minor * 2.5) + } else { + (minor * 0.4, minor * 2.0) + }; + let half = GRID_LEVEL_BLEND * 0.5; + let l = raw.log10(); + // Zooming out, the switch into the coarser level comes at `log10(coarser)`; zooming in, + // the switch into this level was at `log10(minor)`, below which the finer level returns. + let (lo, hi) = if coarser.log10() - l < half { + (minor, coarser) + } else if l - minor.log10() < half && finer >= 1e-3 * (1.0 - 1e-9) { + (finer, minor) + } else { + return None; + }; + let s = ((l - hi.log10()) / GRID_LEVEL_BLEND + 0.5).clamp(0.0, 1.0); + // The switch from 2 to 5 is the only one whose coarser step is not a whole multiple of the + // finer one. It takes the same zoom range as the others, so the grid is cross-fading at the + // same zooms as before; its two halves are each as long as half a nested cross-fade. + let nested = ((hi / lo) - (hi / lo).round()).abs() < 1e-6; + Some(GridBlend { lo, hi, s, nested }) +} + +/// The grid levels drawn for a view of the given half height (#9570). +/// +/// [`grid_step`] switches the spacing in a step (1, 2, 5 times a power of ten), so the whole grid +/// used to swap at once as the view zoomed through a switch, popping over and over while +/// scrolling. Near a switch the drawn grid now cross-fades: the outgoing and incoming levels are +/// both drawn, weighted by how far the zoom is through the switch (from 2 to 5 one after the +/// other, see [`grid_lines`]). Away from a switch only the level [`grid_step`] gives is drawn, at +/// full strength, so the grid looks as it did. The level with the larger weight is always +/// [`grid_step`]'s, which sketch snapping, dash lengths and the tools keep using unchanged: only +/// the drawing fades. +pub fn grid_levels(half_height: f64) -> Vec { + let Some(b) = grid_blend(half_height) else { + let (minor, major) = grid_step(half_height); + return vec![GridLevel { minor, major, weight: 1.0 }]; + }; + let w_hi = b.share_hi(); + [GridLevel { minor: b.lo, major: b.lo * 5.0, weight: 1.0 - w_hi }, GridLevel { minor: b.hi, major: b.hi * 5.0, weight: w_hi }] + .into_iter() + .filter(|g| g.weight > 0.0) + .collect() +} + +/// One line of the drawn grid (#9570), across one of the grid's axes. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GridLineAt { + /// Where the line crosses the axis, measured from the grid's centre. + pub offset: f64, + /// How strongly it is drawn (0..1), before the fade with the distance ([`GridFade`]). + pub weight: f32, + /// How much of that weight is a major line (0..1): the line's colour and width blend from + /// the minor to the major ones by it. + pub major: f32, +} + +/// The lines of the grid across one axis, for a view of the given half height: every level of +/// [`grid_levels`], at the grid's own places on the plane (multiples of its step from the plane's +/// origin), so that a level fading in lies where it will stay once it is the only one. `at` is +/// the grid's centre on that axis, measured from the plane's origin; the lines reach `reach` +/// either side of it, at most `cap` steps of each level. A line two levels share is drawn once, +/// with their weights added; a line only one level has is drawn as strongly as that level's own +/// lines, which from 2 to 5 fade one after the other, never both at once. +pub fn grid_lines(half_height: f64, at: f64, reach: f64, cap: i64) -> Vec { + if !at.is_finite() { + return Vec::new(); + } + // (minor step, share of the cross-fade, strength of its own lines) per level. + let levels: Vec<(f64, f32, f32)> = match grid_blend(half_height) { + Some(b) => { + let (share_hi, (own_lo, own_hi)) = (b.share_hi(), b.own()); + vec![(b.lo, 1.0 - share_hi, own_lo), (b.hi, share_hi, own_hi)] + } + None => vec![(grid_step(half_height).0, 1.0, 1.0)], + }; + // (line, own strength, shared by two levels) + let mut out: Vec<(GridLineAt, f32, bool)> = Vec::new(); + for &(step, share, own) in &levels { + let k0 = (at / step).round(); + if !k0.is_finite() { + continue; + } + let n = ((reach / step) as i64).clamp(1, cap.max(1)); + for i in -n..=n { + let k = (k0 as i64).saturating_add(i); + let major = if k.rem_euclid(5) == 0 { 1.0 } else { 0.0 }; + out.push((GridLineAt { offset: k as f64 * step - at, weight: share, major }, own, false)); + } + } + out.sort_by(|a, b| a.0.offset.total_cmp(&b.0.offset)); + let tol = levels.iter().map(|l| l.0).fold(f64::INFINITY, f64::min) * 1e-6; + let mut merged: Vec<(GridLineAt, f32, bool)> = Vec::with_capacity(out.len()); + for (line, own, _) in out { + match merged.last_mut() { + Some((last, _, shared)) if (line.offset - last.offset).abs() <= tol => { + let weight = last.weight + line.weight; + last.major = if weight > 0.0 { (last.major * last.weight + line.major * line.weight) / weight } else { last.major }; + last.weight = weight.min(1.0); + *shared = true; + } + _ => merged.push((line, own, false)), + } + } + merged.into_iter().map(|(line, own, shared)| if shared { line } else { GridLineAt { weight: own, ..line } }).filter(|l| l.weight > 0.0).collect() +} + /// How the grid fades out with the distance from the middle of the view (#4214). /// /// The grid used to stop at a fixed distance, so its edge showed as a cut across the picture. It @@ -603,7 +780,7 @@ fn build_scene( sc.radius = (b.diagonal() * 0.5).max(cam.half_height()).max(10.0) + b.center().dist(cam.target); // Grid on the ground plane (XY with Z up, XZ with Y up), drawn under the model as in the viewport. if guides.grid { - let (minor, major) = grid_step(cam.half_height()); + let major = grid_step(cam.half_height()).1; // Headless pictures keep the grid on the ground, the viewport's default; an open sketch // has its own plane's grid (world coordinates, no camera frame). let (c, u, v, slant, w): (Vec3, Vec3, Vec3, f64, Box Vec3>) = if let Some(pl) = open_sketch_plane(s) { @@ -624,23 +801,31 @@ fn build_scene( let ext = grid_ext(cam.half_height(), major, slant); let far_reach = grid_far_reach(cam, cam.clip(sc.radius).1, w(c), w(u.cross(v))); let fade = GridFade::new(cam.half_height(), slant, ext, far_reach); - let n = ((fade.end / minor) as i64).clamp(1, 400); - for i in -n..=n { - let t = i as f64 * minor; - let major_line = (t / major).fract().abs() < 1e-9; - let col = if major_line { p.grid_major } else { p.grid }; - // The viewport's widths (pixels). - let width = if major_line { 1.0 } else { 0.7 }; - // Two lines through the point `c + u * t`: one along v, one along u. - for (along, across) in [(v, u), (u, v)] { - for (s0, s1, alpha) in fade.pieces(t) { - sc.grid.push(SceneLine { - points: vec![w(c + across * t + along * s0), w(c + across * t + along * s1)], - color: col, - alpha, - width, - on_top: false, - }); + // The grid's centre on each axis, from the plane's origin (the world's, or the sketch + // plane's): a level fading in near a switch of the spacing lies on its own places (#9570). + let origin = open_sketch_plane(s).map_or(Vec3::ZERO, |pl| pl.origin); + let (at_u, at_v) = ((c - origin).dot(u), (c - origin).dot(v)); + // Two lines through the point `c + u * t`: one along v, one along u. + for (along, across, at) in [(v, u, at_u), (u, v, at_v)] { + for line in grid_lines(cam.half_height(), at, fade.end, 400) { + let t = line.offset; + // The viewport's widths (pixels). The headless picture keeps the palette's two + // grid colours: a line two levels share with different kinds is drawn as both, + // each as strong as its share. + let width = 0.7 + 0.3 * f64::from(line.major); + for (col, share) in [(p.grid_major, line.major), (p.grid, 1.0 - line.major)] { + if share <= 0.0 { + continue; + } + for (s0, s1, alpha) in fade.pieces(t) { + sc.grid.push(SceneLine { + points: vec![w(c + across * t + along * s0), w(c + across * t + along * s1)], + color: col, + alpha: alpha * line.weight * share, + width, + on_top: false, + }); + } } } } @@ -828,7 +1013,7 @@ pub fn sketch_shown(s: &Session, id: u64) -> bool { if s.active_sketch == Some(id) || s.feature_edit_sketch == Some(id) { return true; } - if !sketch_component_visible(s, id) || s.feature_folder_hidden(id, "sketches") { + if !s.feature_placement_visible(id) || s.feature_folder_hidden(id, "sketches") { return false; } if s.doc @@ -845,23 +1030,6 @@ pub fn sketch_shown(s: &Session, id: u64) -> bool { v.shown_sketches.contains(&id) || !sketch_consumed(s, id) } -/// Is the placement a sketch is drawn at shown (the active one while its component is being -/// edited, else the component's first)? -fn sketch_component_visible(s: &Session, id: u64) -> bool { - if s.visibility.hidden_occurrences.is_empty() { - return true; - } - let component = s.doc.feature(id).map_or(0, |f| f.component); - if component == 0 { - return true; - } - let path = match &s.active_occurrence_path { - Some(p) if s.active_component == component => p.clone(), - _ => s.doc.first_path(component), - }; - s.occurrence_visible(&path) -} - /// Is a sketch used by a later feature? A sheet metal base or contour flange uses its sketch as /// an Extrude does: shown, its lines lie on the sheet's edges and take the clicks meant for /// them (#6804). So does a Hole at sketch points: shown, its points are drawn in the holes diff --git a/crates/engine/src/view_palette_tests.rs b/crates/engine/src/view_palette_tests.rs index 4b991e3254..9407bab939 100644 --- a/crates/engine/src/view_palette_tests.rs +++ b/crates/engine/src/view_palette_tests.rs @@ -75,6 +75,133 @@ fn the_grid_reaches_further_as_its_plane_is_seen_at_a_slant() { assert!(grid_ext(40.0, major, 0.0).is_finite()); } +/// #9570: zooming through a switch of the grid's spacing used to swap the whole grid at once, so +/// scrolling made it pop over and over. The drawn grid now cross-fades: zoomed in small steps +/// through a switch, no line's strength jumps from one step to the next, while away from a +/// switch only [`grid_step`]'s level is drawn, at full strength, as before. +#[test] +fn the_grid_cross_fades_between_levels_as_the_view_zooms() { + // Every 1, 2, 5 switch, zoomed by 1% a step from a half height of 8 to 1200. + let strengths = |hh: f64| { + let mut m = std::collections::BTreeMap::new(); + // The grid's centre off the origin, as when the view has panned: each level's lines + // stay on the plane's own places. + let at = 37.0; + for l in grid_lines(hh, at, 60.0 * hh, 4000) { + let world = l.offset + at; + // Weight and major share, keyed by the line's place on the plane (to 1e-6 mm). + m.insert((world * 1e6).round() as i64, (l.weight, l.major)); + } + m + }; + let mut hh = 8.0; + let mut prev = strengths(hh); + let mut faded = 0; + while hh < 1200.0 { + let next_hh = hh * 1.01; + let next = strengths(next_hh); + // Only lines both steps draw: compare within the smaller reach, short of its ends, where + // each level stops at a whole number of its steps. + let reach = ((60.0 * hh - hh / 4.0) * 1e6) as i64; + for key in prev.keys().chain(next.keys()).filter(|k| (**k - 37_000_000).abs() < reach) { + let (a, ma) = prev.get(key).copied().unwrap_or((0.0, 0.0)); + let (b, mb) = next.get(key).copied().unwrap_or((0.0, 0.0)); + assert!((a - b).abs() < 0.1, "the line at {} mm jumps from {a} to {b} zooming from {hh} to {next_hh}", *key as f64 * 1e-6); + if a > 0.05 && b > 0.05 { + assert!((ma - mb).abs() < 0.1, "the line at {} mm turns from major {ma} to {mb} zooming from {hh} to {next_hh}", *key as f64 * 1e-6); + } + } + let levels = grid_levels(next_hh); + let total: f32 = levels.iter().map(|g| g.weight).sum(); + assert!((total - 1.0).abs() < 1e-5, "the levels at {next_hh} add up to {total}"); + // The strongest level is the one snapping and the tools use. + let strongest = levels.iter().fold(levels[0], |a, g| if g.weight > a.weight { *g } else { a }); + assert_eq!((strongest.minor, strongest.major), grid_step(next_hh), "at {next_hh} the drawn grid's main level is grid_step's"); + faded += usize::from(levels.len() == 2); + prev = next; + hh = next_hh; + } + assert!(faded > 0, "some steps cross-fade"); + // Away from a switch (raw step 3 lies between the 2 and 5 switches), one level at full + // strength, as the grid always was. + assert_eq!(grid_levels(30.0), vec![GridLevel { minor: 2.0, major: 10.0, weight: 1.0 }]); + // grid_step's floor and hostile heights draw one level. + for hh in [0.0, 1e-9, -5.0, f64::NAN, f64::INFINITY] { + assert_eq!(grid_levels(hh).len(), 1, "{hh}"); + let _ = grid_lines(hh, f64::NAN, 10.0, 10); + let _ = grid_lines(hh, 1e300, 1e300, 10); + } +} + +/// #9570: the headless picture's grid cross-fades as the viewport's does: right at a nested +/// switch of the spacing (5 to 10), both levels are drawn, the finer one's own lines at half +/// strength, in the palette's grid colours only. Right at the switch from 2 to 5, which do not +/// nest, neither level's own lines are drawn, only the 10 mm lines both share. +#[test] +fn the_headless_grid_draws_both_levels_at_a_switch() { + let s = Session::default(); + let mut cam = home_camera(&s); + cam.pitch = std::f64::consts::FRAC_PI_2; + cam.target = Vec3::ZERO; + let strength = |hh: f64, x: f64| { + let mut cam = cam; + cam.distance *= hh / cam.half_height(); + assert!((cam.half_height() - hh).abs() < 1e-6); + let scene = scene(&s, &cam); + assert!(scene.grid.iter().all(|l| [Palette::DEFAULT.grid, Palette::DEFAULT.grid_major].contains(&l.color))); + // The strongest piece of the line in each grid colour, added: a line two levels share + // with different kinds is drawn in both. + let on = |l: &&SceneLine| (l.points[0].x - x).abs() < 1e-6 && (l.points[1].x - x).abs() < 1e-6; + [Palette::DEFAULT.grid, Palette::DEFAULT.grid_major] + .iter() + .map(|c| scene.grid.iter().filter(on).filter(|l| l.color == *c).map(|l| l.alpha).fold(0.0f32, f32::max)) + .sum::() + }; + // A half height where grid_step switches from 5 to 10: the 5 mm line (only the finer level) + // is drawn at half strength, the 10 mm line (both) at full. + let a = strength(100.0, 5.0); + assert!(a > 0.3 && a < 0.7, "the line at 5 mm is drawn at {a}, half strength while the levels cross-fade"); + assert!(strength(100.0, 10.0) > 0.9); + // A half height where grid_step switches from 2 to 5. + for x in [2.0, 5.0] { + let a = strength(50.0, x); + assert!(a < 1e-6, "the line at {x} mm is drawn at {a} while 2 and 5 swap"); + } + assert!(strength(50.0, 10.0) > 0.9); +} + +/// #9570: the levels 2 and 5 do not nest, and drawn together they gave lines at 0, 2, 4, 5, 6, +/// 8, 10 mm, which looked restless. Zooming through that switch the finer level's own lines are +/// gone before the coarser level's own lines appear, while the lines both share (every 10 mm) +/// stay at full strength, so the grid never goes blank. Checked in every decade drawn. +#[test] +fn the_grid_never_draws_two_levels_that_do_not_nest_at_once() { + for scale in [0.1, 1.0, 10.0, 100.0] { + // grid_step switches from 2 to 5 (times `scale`) at a half height of 50 times `scale`. + let (mut hh, end) = (30.0 * scale, 80.0 * scale); + let (mut saw_fine, mut saw_coarse) = (false, false); + while hh < end { + let lines = grid_lines(hh, 0.0, 60.0 * scale, 4000); + let at = |x: f64| lines.iter().filter(|l| (l.offset - x * scale).abs() < 1e-6 * scale).map(|l| l.weight).sum::(); + // 2, 4, 6, 8: only the finer level; 5, 15: only the coarser. + let fine = [2.0, 4.0, 6.0, 8.0].iter().map(|x| at(*x)).fold(0.0f32, f32::max); + let coarse = [5.0, 15.0].iter().map(|x| at(*x)).fold(0.0f32, f32::max); + assert!(fine == 0.0 || coarse == 0.0, "at {hh} the 2 and 5 levels are both drawn ({fine}, {coarse})"); + saw_fine |= fine > 0.0 && fine < 1.0; + saw_coarse |= coarse > 0.0 && coarse < 1.0; + for x in [0.0, 10.0, 20.0] { + assert!(at(x) > 0.999, "at {hh} the line at {x} mm both levels share is drawn at {}", at(x)); + } + hh *= 1.005; + } + assert!(saw_fine && saw_coarse, "both levels fade rather than swap (scale {scale})"); + } + // The nested switches still cross-fade both levels together: from 1 to 2 (half height 20). + let lines = grid_lines(20.0, 0.0, 20.0, 4000); + let at = |x: f64| lines.iter().filter(|l| (l.offset - x).abs() < 1e-6).map(|l| l.weight).sum::(); + assert!(at(1.0) > 0.3 && at(1.0) < 0.7 && at(2.0) > 0.999, "{} {}", at(1.0), at(2.0)); +} + /// No colour here is one of [`colors`] or an appearance below, and each field has its own. const MARK: Palette = Palette { background: (Rgb(1, 2, 3), Rgb(4, 5, 6)), diff --git a/crates/engine/tests/sketch_drag_target.rs b/crates/engine/tests/sketch_drag_target.rs index 883907e93b..1d72ac72a0 100644 --- a/crates/engine/tests/sketch_drag_target.rs +++ b/crates/engine/tests/sketch_drag_target.rs @@ -29,6 +29,52 @@ fn angular_drag_keeps_the_horizontal_construction_line() { } } +/// A line's end on a horizontal construction line through the origin, dragged up off it in +/// small frames (#9062): the end can only slide along the construction line, so it stays on it +/// and the construction line keeps its length. A line's horizontal row is `b.y - a.y`, which a +/// line a hair long meets at any tilt: the drag's solves shrank the construction line to 1e-5 mm +/// on the first frame and to 1e-7 mm by the tenth, so it vanished and the end went up. +#[test] +fn an_end_dragged_off_a_horizontal_construction_line_leaves_it_whole() { + let through_origin = [ + // Drawn from the origin (inferred horizontal). + vec![("sketch.line", json!({"points":["origin",[20,0]],"construction":true,"infer":true,"ids":["c"]}))], + // Drawn across the origin, then the origin made coincident with it. + vec![ + ("sketch.line", json!({"points":[[-10,0.2],[20,0.2]],"construction":true,"infer":true,"ids":["c"]})), + ("sketch.constraint.coincident", json!({"a":"origin","b":"c"})), + ], + ]; + for construction in through_origin { + for live in [false, true] { + let mut s = solidcraft_engine::Session::default(); + s.execute("sketch.create", &json!({"plane":"XY"})).unwrap(); + for (id, params) in construction.iter().cloned().chain([ + ("sketch.line", json!({"points":[[10,0],[10,10]],"ids":["l"],"infer":false})), + ("sketch.constraint.coincident", json!({"a":"l.start","b":"c"})), + ]) { + s.execute(id, ¶ms).unwrap(); + } + let point = |s: &solidcraft_engine::Session, id| four_bar::point(s, id).unwrap(); + let length = |s: &solidcraft_engine::Session| point(s, "c.start").dist(point(s, "c.end")); + let before = length(&s); + let mut at = point(&s, "l.start"); + for _ in 0..10 { + let to = Vec2::new(at.x, at.y + 0.1); + s.execute("sketch.drag", &json!({"entity":"l.start","from":[at.x,at.y],"to":[to.x,to.y],"live":live})).unwrap(); + assert!((length(&s) - before).abs() < 1e-6, "(live {live}) the construction line went from {before} mm to {} mm", length(&s)); + at = to; + } + if live { + s.execute("sketch.drag_end", &json!({})).unwrap(); + } + let end = point(&s, "l.start"); + assert!(end.y.abs() < 1e-6, "(live {live}) l.start left the construction line: {end:?}"); + assert!((length(&s) - before).abs() < 1e-6, "(live {live}) the construction line went to {} mm", length(&s)); + } + } +} + /// A line held at its midpoint, with an angle to a horizontal line from that point (#8944): /// dragging one of its ends off that angle slides the end along the line as far as it can go, /// and the horizontal line keeps its length. That line's far end is free only along it, which @@ -400,3 +446,53 @@ fn a_linkage_held_by_redundant_parallelograms_follows_the_cursor() { assert!(parallelogram_error(&s) < 1e-6); } } + +#[test] +fn a_drag_that_would_fling_points_far_away_leaves_them_near_the_sketch() { + let mut s = solidcraft_engine::Session::default(); + for (id, params) in [ + ("sketch.create", json!({"plane":"XY"})), + ("sketch.line", json!({"points":[[2,-27],[-2,22],[-10,-10],[2,13]]})), + ("sketch.rectangle.two_point", json!({"p0":[-12,1],"p1":[-18,28]})), + ("sketch.line", json!({"points":[[6,-21],[-12,-27],[-25,-29],[16,5]]})), + ("sketch.rectangle.two_point", json!({"p0":[-21,0],"p1":[-23,-3]})), + ("sketch.line", json!({"points":[[24,23],[-7,7],[0,29],[-6,-7]]})), + ("sketch.dimension", json!({"entities":["l16","l12"],"type":"angle","value":31})), + ] { + s.execute(id, ¶ms).unwrap(); + } + s.execute("sketch.drag", &json!({"entity":"p8","from":[-23.0,-0.534780330115472],"to":[23,-8]})).unwrap(); + for i in 0..30 { + let Ok(p) = four_bar::point(&s, &format!("p{i}")) else { continue }; + assert!(p.len() < 1000.0, "p{i} was thrown to {p:?}"); + } +} + +/// A line held at its midpoint, with an angle to a horizontal line from there: its end dragged +/// past the midpoint, where the line would have to turn over, pressed it to no length (the angle +/// then held no direction). The drag now stops just short of that, the line still pointing the +/// way the angle says. +#[test] +fn an_end_dragged_past_its_lines_midpoint_leaves_the_line_a_length() { + for live in [false, true] { + let mut s = solidcraft_engine::Session::default(); + for (id, params) in [ + ("sketch.create", json!({"plane":"XY"})), + ("sketch.line", json!({"points":[[-10,-6],[10,6]],"infer":false})), + ("sketch.constraint.midpoint", json!({"line":"l1","point":"origin"})), + ("sketch.line", json!({"points":["origin",[15,0]],"infer":false})), + ("sketch.constraint.horizontal_vertical", json!({"line":"l2"})), + ("sketch.dimension", json!({"entities":["l1","l2"],"type":"angle"})), + ("sketch.drag", json!({"entity":"l1.start","from":[-10,-6],"to":[12,4],"live":live})), + ] { + s.execute(id, ¶ms).unwrap(); + } + if live { + s.execute("sketch.drag_end", &json!({})).unwrap(); + } + let (a, b) = (four_bar::point(&s, "l1.start").unwrap(), four_bar::point(&s, "l1.end").unwrap()); + assert!(a.dist(b) > 1e-3, "l1 is left {} mm long (live {live})", a.dist(b)); + let d = b - a; + assert!((d.y.atan2(d.x) - 6f64.atan2(10.0)).abs() < 1e-6, "l1 turned to {d:?} (live {live})"); + } +} diff --git a/crates/gpu/src/capture.rs b/crates/gpu/src/capture.rs index 5dbae9b5c8..c82e2bab30 100644 --- a/crates/gpu/src/capture.rs +++ b/crates/gpu/src/capture.rs @@ -405,6 +405,7 @@ fn frame(scene: &Scene, cam: &Camera, [width, height]: [u32; 2], key: u64, acces eye: [eye[0], eye[1], eye[2], if cam.fov > 0.0 { 1.0 } else { 0.0 }], ao_radius: 0.0, ao_samples: crate::view::AO_SAMPLES, + sky: [0.0; 4], ssaa: 1.0, hover_tint: [0.0; 3], selection_tint: [0.0; 3], diff --git a/crates/gpu/src/view.rs b/crates/gpu/src/view.rs index e005349788..d11819683c 100644 --- a/crates/gpu/src/view.rs +++ b/crates/gpu/src/view.rs @@ -153,8 +153,8 @@ fn octahedral6(n: [f32; 3]) -> u32 { } /// The shader's `U`: the view-projection matrix, 10 `vec4`s (the tenth carries the ambient /// occlusion radius), the 8 analysis colours, their flags, the depth direction and the two -/// whole-body highlight colours (`uniform_bytes`). -const UNIFORM_SIZE: u64 = 416 + 16 + 21 * 64; +/// whole-body highlight colours, the product lights, and the sky (`uniform_bytes`). +const UNIFORM_SIZE: u64 = 416 + 16 + 21 * 64 + 16; /// A body drawn from its display plan, evaluated on the GPU: the plan, and a palette index per /// face (the plan's face numbering; faces past the end take the last index). @@ -990,6 +990,9 @@ pub struct Frame { pub hover_tint: [f32; 3], /// Whole-body selection highlight: a selected body's colour before lighting (sRGB 0..1). pub selection_tint: [f32; 3], + /// The world's up direction and 1 to shade in linear light with a sky fill that follows it + /// (#7974); 0 shades as before. + pub sky: [f32; 4], } /// Heights of the model seen from a direction (an orthographic depth map): a point is reachable @@ -3309,6 +3312,7 @@ fn frame_key(cb: &Frame) -> u64 { // picture cached under the previous theme's colours is not shown (#5802). cb.hover_tint.map(f32::to_bits).hash(&mut h); cb.selection_tint.map(f32::to_bits).hash(&mut h); + cb.sky.map(f32::to_bits).hash(&mut h); cb.outline.hash(&mut h); cb.ao_radius.to_bits().hash(&mut h); cb.ao_samples.hash(&mut h); @@ -4169,6 +4173,7 @@ pub fn uniform_bytes(cb: &Frame, w: f32, h: f32, linear: bool, reversed: bool) - let light = cb.product_lights.as_ref().and_then(|lights| lights.get(index)).copied().unwrap_or([0.0; 16]); v.extend(light.map(|value| if value.is_finite() { value } else { 0.0 })); } + v.extend(cb.sky.map(|value| if value.is_finite() { value } else { 0.0 })); v.iter().flat_map(|x| x.to_le_bytes()).collect() } @@ -4364,6 +4369,28 @@ mod tests { assert_eq!(samples_sent(1000), 32.0, "at most the shader's 32"); } + /// The sky (#7974) is the uniform's last `vec4`: the world's up direction and the switch, with + /// anything not finite sent as zero (shaded as before). + #[test] + fn the_sky_rides_at_the_end_of_the_uniform() { + let last = |sky: [f32; 4]| { + let mut f = cached_frame(); + f.sky = sky; + let bytes = super::uniform_bytes(&f, 64.0, 64.0, false, false); + assert_eq!(bytes.len() as u64, super::UNIFORM_SIZE); + let v: Vec = bytes.as_chunks::<4>().0.iter().map(|c| f32::from_le_bytes(*c)).collect(); + v[v.len() - 4..].to_vec() + }; + assert_eq!(last([0.0, 0.0, 1.0, 1.0]), [0.0, 0.0, 1.0, 1.0]); + assert_eq!(last([0.0; 4]), [0.0; 4]); + assert_eq!(last([f32::NAN, 1.0, f32::INFINITY, 1.0]), [0.0, 1.0, 0.0, 1.0]); + // Turning it on draws the frame again. + let mut f = cached_frame(); + let off = super::frame_key(&f); + f.sky = [0.0, 0.0, 1.0, 1.0]; + assert_ne!(super::frame_key(&f), off); + } + #[test] fn render_lights_preview_shader_uniform_and_frame_cache_include_light_state() { let source = format!("{}\n{}", super::SHADER, crate::pbr::SOURCE); @@ -4415,6 +4442,7 @@ mod tests { eye: [0.0; 4], ao_radius: 0.0, ao_samples: crate::view::AO_SAMPLES, + sky: [0.0; 4], ssaa: 1.0, } } @@ -4800,6 +4828,7 @@ mod glass_tests { eye: [0.0, 0.0, 100.0, 0.0], ao_radius: 0.0, ao_samples: crate::view::AO_SAMPLES, + sky: [0.0; 4], ssaa: 1.0, hover_tint: [0.0; 3], selection_tint: [0.0; 3], @@ -4977,6 +5006,7 @@ mod glass_tests { eye: [0.0, 0.0, 100.0, 0.0], ao_radius: 0.0, ao_samples: crate::view::AO_SAMPLES, + sky: [0.0; 4], ssaa, } } diff --git a/crates/gpu/src/view/msaa_check.rs b/crates/gpu/src/view/msaa_check.rs index a70e7ab593..cc26caea40 100644 --- a/crates/gpu/src/view/msaa_check.rs +++ b/crates/gpu/src/view/msaa_check.rs @@ -121,6 +121,7 @@ impl Renderer { eye: [0.0, 0.0, 100.0, 0.0], ao_radius: 0.0, ao_samples: crate::view::AO_SAMPLES, + sky: [0.0; 4], ssaa: 1.0, hover_tint: [0.0; 3], selection_tint: [0.0; 3], diff --git a/crates/gpu/tests/capture.rs b/crates/gpu/tests/capture.rs index 03927f4ba4..d7782b76a1 100644 --- a/crates/gpu/tests/capture.rs +++ b/crates/gpu/tests/capture.rs @@ -319,6 +319,28 @@ fn pictures_show_the_surface_analysis() { assert!(reachable > 1000 && under > 1000, "accessibility: {reachable} reachable, {under} blocked pixels"); } +/// A flat face of a box keeps its zebra colour while the view turns a little at a time (#2153): +/// a horizontal face reflected straight ahead, on the edge between a black and a white stripe, +/// and rounding flipped it from one step to the next. +#[test] +fn zebra_on_flat_faces_holds_still_while_the_view_turns() { + let Some(c) = capturer() else { return }; + let mut s = session(&[("solid.box", json!({"length": 60, "width": 40, "height": 20}))]); + s.execute("inspect.zebra", &json!({})).unwrap(); + let home = view::home_camera(&s); + let whites: Vec = (0..24) + .map(|step| { + let mut cam = home; + cam.yaw += f64::from(step) * 0.11_f64.to_radians(); + let img = c.rgba(&view::scene_with_guides(&s, &cam, None, Guides { grid: false, axes: false }), &cam, 320, 200).unwrap(); + pixels(&img).filter(|p| p[0] > 225 && p[1] > 225 && p[2] > 225).count() + }) + .collect(); + let (least, most) = (whites.iter().min().copied().unwrap_or(0), whites.iter().max().copied().unwrap_or(0)); + assert!(most > 1000, "white stripes: {whites:?}"); + assert!((most - least) * 10 < most, "white pixels jump while the view turns by 0.11 degree steps: {whites:?}"); +} + fn colors_of(c: [[f32; 3]; 2]) -> [[u8; 3]; 2] { c.map(|rgb| rgb.map(|x| (x * 255.0).round() as u8)) } diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 6b2d1c2a3f..6e0455f679 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -44,7 +44,7 @@ pub use dxf_out::{DxfEntity, DxfLayer, DxfLinetype, DxfShape, loop_shapes, seg_s pub use legacy_dir::{APP_ID, LEGACY_APP_ID, PREVIOUS_APP_IDS, adopt_legacy_dir, legacy_flatpak_paths}; pub use safe_write::{backup_path, write_atomic}; pub use sketch2d::{Geom2, MAX_DRAWING_BYTES}; -pub use svg::read_svg; +pub use svg::{decode_svg, read_svg, svg_nothing_to_import}; mod gltf_in; pub use gltf_in::{GltfImport, Loader, MAX_GLTF_BYTES, MeshPlan, PlanPlace, glb_container, is_gltf_path, read_gltf}; mod obj; diff --git a/crates/io/src/safe_write.rs b/crates/io/src/safe_write.rs index 71f890fb18..ebe9184a28 100644 --- a/crates/io/src/safe_write.rs +++ b/crates/io/src/safe_write.rs @@ -1,7 +1,8 @@ //! Crash-safe file writes: the new content goes to a temporary file next to the target, is //! flushed to disk, and replaces the target in one rename. A crash or power cut at any moment //! leaves the old file or the new one, never a torn mix. The previous version can be kept as -//! `.bak` (itself written the same way). +//! `.bak`: the old file itself under that name (a hard link) where the file system allows, +//! else a copy written the same way. use std::fs::{self, OpenOptions}; use std::io::Write; @@ -145,6 +146,45 @@ fn rename_with_retry( result } +/// Keep the file a save is about to replace as its backup without copying it: a second name for +/// the same file (a hard link) under a temporary name beside it, renamed over the old backup in +/// one step. The target keeps its name at every moment, and a save never writes into a file it +/// replaces (it renames a new one over it), so the backup stays the version it was. `Ok(false)`: +/// this file system refuses links (FAT and exFAT drives, many network shares and synced cloud +/// folders) or the old file could not be flushed first; the caller copies it then, as before. A +/// refused rename fails the save, as it does for a copy. +/// +/// What differs from a copy: the backup is the old file itself, so it keeps that file's +/// modification time (the previous save's, where a copy has the time of this save), its owner +/// and its permissions (a copy is given the same permissions), and until the target is replaced +/// the two names share one file. +fn link_backup(path: &Path) -> std::io::Result { + #[cfg(test)] + if tests::LINKS_REFUSED.with(std::cell::Cell::get) { + return Ok(false); + } + // Flushed first, as a copy is: data still only in memory would be lost with it in a power cut. + if open_to_flush(path).and_then(|f| flush(&f)).is_err() { + return Ok(false); + } + let bak_tmp = temp_beside(path, "bak"); + if fs::hard_link(path, &bak_tmp).is_err() { + return Ok(false); + } + if let Err(e) = replace_synced(&bak_tmp, &backup_path(path)) { + // Only the second name this call just made: the old file keeps its own. + let _ = fs::remove_file(&bak_tmp); + return Err(e); + } + Ok(true) +} + +/// Open the file a save replaces so it can be flushed. Windows flushes only a file opened for +/// writing (nothing is written, and it is not truncated); elsewhere reading is enough. +fn open_to_flush(path: &Path) -> std::io::Result { + OpenOptions::new().read(true).write(cfg!(windows)).open(path) +} + fn write_steps(path: &Path, bytes: &[u8], backup: bool, stop: Option) -> std::io::Result<()> { let permissions = fs::metadata(path).ok().filter(|m| m.is_file()).map(|m| m.permissions()); let tmp = temp_beside(path, "new"); @@ -156,7 +196,15 @@ fn write_steps(path: &Path, bytes: &[u8], backup: bool, stop: Option) -> s if stop == Some(Step::TempWritten) { return Ok(()); } - if backup && path.is_file() { + let linked = if backup && path.is_file() { link_backup(path) } else { Ok(false) }; + let linked = match linked { + Ok(linked) => linked, + Err(e) => { + let _ = fs::remove_file(&tmp); + return Err(e); + } + }; + if backup && !linked && path.is_file() { // Copy (not move) the old file, so the target exists at every moment. let old = fs::read(path); let bak_tmp = temp_beside(path, "bak"); @@ -245,6 +293,86 @@ pub fn stale_temps(path: &Path) -> Vec { mod tests { use super::*; + thread_local! { + /// This thread's saves find hard links refused ([`link_backup`]), as on a FAT drive: the + /// backup is copied, exactly as it is there. + pub(super) static LINKS_REFUSED: std::cell::Cell = const { std::cell::Cell::new(false) }; + } + + /// A new, empty folder of its own for one test (nothing in it is removed). + fn fresh_dir(tag: &str) -> PathBuf { + let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0); + let d = std::env::temp_dir().join(format!("solidcraft-safe-write-{tag}-{}-{nanos}", std::process::id())); + fs::create_dir_all(&d).unwrap(); + d + } + + /// The time of a save with its backup, the backup linked against the backup copied (links + /// refused, as before hard links were used), taking turns. The file saved is + /// `SOLIDCRAFT_SAVE_BENCH_FILE` (a design), in a new folder in `SOLIDCRAFT_SAVE_BENCH_DIR` + /// (on the drive to measure; else the temporary folder). Run with `--ignored --nocapture`. + #[test] + #[ignore = "manual save measurement"] + fn save_time_with_the_backup_linked_or_copied() { + let Some(file) = std::env::var_os("SOLIDCRAFT_SAVE_BENCH_FILE") else { return }; + let bytes = fs::read(file).unwrap(); + let base = std::env::var_os("SOLIDCRAFT_SAVE_BENCH_DIR").map(PathBuf::from).unwrap_or_else(std::env::temp_dir); + let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0); + let d = base.join(format!("save-bench-{}-{nanos}", std::process::id())); + fs::create_dir_all(&d).unwrap(); + let p = d.join("design.solidcraft"); + write_atomic(&p, &bytes, true).unwrap(); + let rounds: usize = std::env::var("SOLIDCRAFT_SAVE_BENCH_ROUNDS").ok().and_then(|n| n.parse().ok()).unwrap_or(11); + let mut times = [Vec::new(), Vec::new()]; + for round in 0..2 * rounds { + let refused = round % 2 == 1; + LINKS_REFUSED.with(|r| r.set(refused)); + let t = std::time::Instant::now(); + write_atomic(&p, &bytes, true).unwrap(); + times[usize::from(refused)].push(t.elapsed().as_secs_f64() * 1000.0); + } + LINKS_REFUSED.with(|r| r.set(false)); + for (name, mut t) in ["linked", "copied"].into_iter().zip(times) { + t.sort_by(f64::total_cmp); + println!( + "SAVEBENCH bytes={} backup={name} median_ms={:.2} min_ms={:.2} max_ms={:.2} n={}", + bytes.len(), + t[t.len() / 2], + t[0], + t[t.len() - 1], + t.len() + ); + } + } + + /// Saves keep the previous version as the backup, stopped after any step or not, whether the + /// file system links the old file as the backup or refuses links (a FAT drive, a share) so it + /// is copied: the design file is the old version or the new one, whole, and the backup is + /// the version before it, never the one being written. + #[test] + fn the_backup_is_the_previous_version_linked_or_copied_and_a_stopped_save_keeps_both() { + for refused in [false, true] { + LINKS_REFUSED.with(|r| r.set(refused)); + let d = fresh_dir(if refused { "bak-copied" } else { "bak-linked" }); + let p = d.join("a.solidcraft"); + write_atomic(&p, b"one", true).unwrap(); + write_atomic(&p, b"two", true).unwrap(); + assert_eq!((fs::read(&p).unwrap(), fs::read(backup_path(&p)).unwrap()), (b"two".to_vec(), b"one".to_vec()), "refused {refused}"); + for stop in [Step::TempWritten, Step::BackupWritten] { + write_steps(&p, b"three", true, Some(stop)).unwrap(); + assert_eq!(fs::read(&p).unwrap(), b"two", "{stop:?}, refused {refused}"); + let bak = fs::read(backup_path(&p)).unwrap(); + let want: &[u8] = if stop == Step::TempWritten { b"one" } else { b"two" }; + assert_eq!(bak, want, "{stop:?}, refused {refused}"); + assert!(!stale_temps(&p).is_empty(), "the stopped save's temporary file is left over"); + } + write_atomic(&p, b"four", true).unwrap(); + write_atomic(&p, b"five", true).unwrap(); + assert_eq!((fs::read(&p).unwrap(), fs::read(backup_path(&p)).unwrap()), (b"five".to_vec(), b"four".to_vec()), "refused {refused}"); + } + LINKS_REFUSED.with(|r| r.set(false)); + } + /// #5467: a lock is someone else's while it is refreshed; one left by a crash goes stale, and /// this app's own lock never counts as the design being open elsewhere. Writing a design's /// file changes its stamp. @@ -471,6 +599,34 @@ mod tests { let _ = fs::remove_dir_all(&d); } + /// Where links work the backup is the old file itself, not a copy: its inode, its + /// modification time and its permissions, with one name again once the save is done; + /// where they are refused it is a copy with the old file's permissions. + #[test] + fn the_backup_is_the_old_file_linked_or_a_copy_where_links_are_refused() { + use std::os::unix::fs::MetadataExt; + for refused in [false, true] { + LINKS_REFUSED.with(|r| r.set(refused)); + let d = fresh_dir(if refused { "inode-copied" } else { "inode-linked" }); + let p = d.join("a.solidcraft"); + write_atomic(&p, b"one", true).unwrap(); + fs::set_permissions(&p, fs::Permissions::from_mode(0o640)).unwrap(); + let old = fs::metadata(&p).unwrap(); + write_atomic(&p, b"two", true).unwrap(); + let bak = fs::metadata(backup_path(&p)).unwrap(); + let new = fs::metadata(&p).unwrap(); + assert_ne!(new.ino(), old.ino(), "the design file is a new file"); + assert_eq!(bak.ino() == old.ino(), !refused, "linked: the backup is the old file"); + if !refused { + assert_eq!(bak.modified().unwrap(), old.modified().unwrap(), "it keeps the old file's time"); + } + assert_eq!((bak.nlink(), new.nlink()), (1, 1), "one name each once the save is done"); + assert_eq!((mode(&p), mode(&backup_path(&p))), (0o640, 0o640), "refused {refused}"); + assert_eq!(fs::read(backup_path(&p)).unwrap(), b"one"); + } + LINKS_REFUSED.with(|r| r.set(false)); + } + #[test] fn a_save_through_a_link_keeps_the_targets_permissions() { let d = dir("linkmode"); diff --git a/crates/io/src/svg.rs b/crates/io/src/svg.rs index f76b4e752e..39cc177111 100644 --- a/crates/io/src/svg.rs +++ b/crates/io/src/svg.rs @@ -525,6 +525,84 @@ pub fn read_svg(text: &str) -> Result> { Ok(out.g) } +/// The text of an SVG file's bytes: a UTF-8 byte-order mark is dropped and UTF-16 files (with +/// their mark, as some Windows tools write them) are decoded. A gzip-compressed file (`.svgz` +/// under any name) is refused with a clear error instead of being read as text (#9641). +pub fn decode_svg(bytes: &[u8]) -> Result { + if bytes.len() > MAX_DRAWING_BYTES { + return Err(IoError::Invalid("the SVG file is too large".into())); + } + if bytes.starts_with(&[0x1f, 0x8b]) { + return Err(IoError::Invalid( + "this SVG is gzip-compressed (an .svgz file); SolidCraft reads plain SVG only: save it as plain (uncompressed) SVG in a vector editor first" + .into(), + )); + } + let utf16 = |rest: &[u8], le: bool| -> String { + let units = rest.as_chunks::<2>().0.iter().map(|&pair| if le { u16::from_le_bytes(pair) } else { u16::from_be_bytes(pair) }); + char::decode_utf16(units).map(|c| c.unwrap_or(char::REPLACEMENT_CHARACTER)).collect() + }; + Ok(match bytes { + [0xEF, 0xBB, 0xBF, rest @ ..] => String::from_utf8_lossy(rest).into_owned(), + [0xFF, 0xFE, rest @ ..] => utf16(rest, true), + [0xFE, 0xFF, rest @ ..] => utf16(rest, false), + _ => String::from_utf8_lossy(bytes).into_owned(), + }) +} + +/// Why [`read_svg`] found nothing to import in `text`, in words for the person inserting it +/// (#9641): a picture embedded in an SVG, shapes drawn only from another file, or a file that is +/// not an SVG drawing at all, rather than a bare "no geometry". +pub fn svg_nothing_to_import(text: &str) -> String { + let mut rd = Reader::from_reader(text.as_bytes()); + rd.config_mut().trim_text(true); + let mut buf = Vec::new(); + let (mut svg, mut images, mut external_uses, mut elements) = (false, 0usize, 0usize, 0usize); + let mut broken = None; + while elements < MAX_ENTITIES { + match rd.read_event_into(&mut buf) { + Ok(Event::Start(e) | Event::Empty(e)) => { + elements += 1; + match e.local_name().as_ref() { + b"svg" => svg = true, + b"image" => images += 1, + b"use" => { + let a = attrs(&e); + if attr(&a, "href").is_some_and(|h| !h.trim_start().starts_with('#')) { + external_uses += 1; + } + } + _ => {} + } + } + Ok(Event::Eof) => break, + Ok(_) => {} + Err(e) => { + broken = Some(e.to_string()); + break; + } + } + buf.clear(); + } + if !svg { + return match broken { + Some(e) => format!("the file is not a readable SVG drawing ({e})"), + None => "the file is not an SVG drawing: it has no element (it may be a picture or a web page saved with an .svg name)".into(), + }; + } + if images > 0 { + return format!( + "the SVG holds only {} embedded picture{} (), not outlines, so there is nothing to draw in the sketch; trace the picture into paths in a vector editor, or save it as PNG/JPEG and use Insert > Canvas, then Canvas to Sketch", + if images == 1 { "an" } else { "several" }, + if images == 1 { "" } else { "s" } + ); + } + if external_uses > 0 { + return "the SVG's shapes are drawn with from another file, which SolidCraft does not follow; save the drawing as a single self-contained SVG first".into(); + } + "the SVG has no shapes SolidCraft reads (path, line, rect, circle, ellipse, polyline, polygon or text)".into() +} + #[cfg(test)] mod tests { use super::*; @@ -589,6 +667,36 @@ mod tests { assert!(matches!(g[0], Geom2::Line(a, b) if (a.dist(b) - 25.4).abs() < 1e-9)); } + /// #9641: a file with nothing to import says why (an embedded picture, shapes from another + /// file, not an SVG at all), and SVG files saved as UTF-16 or with a byte-order mark read. + #[test] + fn a_drawing_with_nothing_to_import_says_why() { + let picture = r##" + + "##; + assert!(read_svg(picture).unwrap().is_empty()); + let why = svg_nothing_to_import(picture); + assert!(why.contains("embedded picture") && why.contains("Canvas"), "{why}"); + let external = r#""#; + assert!(svg_nothing_to_import(external).contains("another file"), "{}", svg_nothing_to_import(external)); + assert!(svg_nothing_to_import("Not found").contains("not an SVG drawing")); + assert!(svg_nothing_to_import("").contains("no shapes")); + + let square = r#""#; + let le: Vec = [0xFF, 0xFE].into_iter().chain(square.encode_utf16().flat_map(u16::to_le_bytes)).collect(); + let be: Vec = [0xFE, 0xFF].into_iter().chain(square.encode_utf16().flat_map(u16::to_be_bytes)).collect(); + let bom: Vec = [0xEF, 0xBB, 0xBF].into_iter().chain(square.bytes()).collect(); + for bytes in [le, be, bom, square.as_bytes().to_vec()] { + let text = decode_svg(&bytes).unwrap(); + assert_eq!(read_svg(&text).unwrap().len(), 4, "{bytes:?}"); + } + let gz = decode_svg(&[0x1f, 0x8b, 8, 0, 0, 0]).unwrap_err().to_string(); + assert!(gz.contains("gzip"), "{gz}"); + for bytes in [&[0xFF, 0xFE, 0x3C][..], &[0xFE, 0xFF, 0xD8, 0x00][..], &[][..]] { + assert!(decode_svg(bytes).is_ok()); + } + } + /// A transform that scales a shape past the range of `f64` is refused with a clear error /// instead of handing the sketch an infinite curve (solidcraft#5020). #[test] diff --git a/crates/io/src/vfs.rs b/crates/io/src/vfs.rs index 2c6f6c7603..ac21126dd1 100644 --- a/crates/io/src/vfs.rs +++ b/crates/io/src/vfs.rs @@ -46,6 +46,13 @@ mod imp { touched(); std::fs::metadata(path).map(|m| m.len()) } + /// The file's modification time and size (`None`: not there, or the platform keeps no times). + pub fn stamp(path: &str) -> Option<(std::time::SystemTime, u64)> { + check(path).ok()?; + touched(); + let meta = std::fs::metadata(path).ok()?; + Some((meta.modified().ok()?, meta.len())) + } pub fn exists(path: &str) -> bool { check(path).is_ok() && { touched(); @@ -108,6 +115,10 @@ mod imp { pub fn len(path: &str) -> Result { FILES.with(|f| f.borrow().get(path).map(|b| b.len() as u64)).ok_or_else(|| missing(path)) } + /// Browser storage keeps no modification times. + pub fn stamp(_path: &str) -> Option<(std::time::SystemTime, u64)> { + None + } pub fn exists(path: &str) -> bool { FILES.with(|f| f.borrow().contains_key(path)) } @@ -566,6 +577,12 @@ pub fn design_path_is_file(path: &str, design: Option<&str>) -> Option { design_path_refusal(path, design).is_none().then(|| is_file(path)) } +/// [`stamp`] of a path a design names; `None` when [`check_design_path`] refuses it, which asks +/// nothing of the disk or the network. +pub fn design_path_stamp(path: &str, design: Option<&str>) -> Option<(std::time::SystemTime, u64)> { + design_path_refusal(path, design).is_none().then(|| stamp(path)).flatten() +} + /// The checks the layers below ask before reading a file a design names (sketch text's font /// files). Done once; every session and agent scope does it. pub fn install_checks() { diff --git a/crates/mcp/src/server.rs b/crates/mcp/src/server.rs index 8f9fc4efe6..0b4662a778 100644 --- a/crates/mcp/src/server.rs +++ b/crates/mcp/src/server.rs @@ -25,17 +25,37 @@ const RESOURCE_NOT_FOUND: i64 = -32002; /// Most messages in one JSON-RPC batch. const MAX_BATCH: usize = 1000; +/// What every MCP client is told at `initialize`: how to plan, build and verify a part. It stays +/// version-neutral (no workaround for a quirk of the current kernel or solver: those belong in +/// `skills/solidcraft-modeling/reference.md`, which `skill_reference` serves per release), and +/// `tests::instructions_name_only_real_tools` checks every tool and command id it names. const INSTRUCTIONS: &str = "SolidCraft is an independent parametric 3D CAD app, and these tools drive it: when its window is \ -open the user watches the model appear, otherwise a headless session builds it. When asked to design or build a part \ -(a bracket, an enclosure, a gear) do it with these tools straight away; nothing needs explaining first. Units are millimetres \ -and degrees in expressions (internally radians), Z is up. Model like this: `sketch.create {plane: XY}`, draw \ -(`sketch.rectangle.two_point {p0, p1}`, `sketch.circle.center {center, radius}`, `sketch.line {points, closed}`), constrain \ -and dimension (`sketch.dimension {entities, value}`; values may name parameters), `sketch.finish`, then features \ -(`solid.extrude {distance, operation?: new|join|cut|intersect}`, `solid.revolve`, `solid.fillet {edges: [[x,y,z] point on \ -edge], radius}`, `solid.chamfer`, patterns, mirror, primitives). Use `batch` for several steps at once, \ -`list_commands` for ids and parameter docs, `describe_command` for a command's schemas, `inspect_design` / `measure` to verify, \ -`body_topology` to find edge points, `set_parameter` to drive the design and `screenshot` to look at it. A call whose error \ -starts `still running:` was not cancelled: don't send it again; `pending` waits for its result."; +open the user watches the model appear, otherwise a headless session builds it. When asked to design or build a part, do it \ +with these tools straight away. Units are millimetres and degrees in expressions, Z is up.\n\n\ +Start: for anything beyond one primitive, call `skill_reference {\"skill\":\"solidcraft-modeling\"}` first (command shapes, \ +ids, plane axes, worked examples and the known traps of this version; `file: \"SKILL.md\"` has the workflow). Then \ +`inspect_design {\"measure\":false,\"sketches\":false}` to see what is already there.\n\n\ +Plan: work the numbers out first (the parameters, the expected volume and bounding box after each stage, the world point you \ +will use for each edge or face pick). Then build in a few `batch` calls (all-or-nothing; the reply names the failing step; \ +`save`, `measure` and `set_parameter` can be steps). Use `verbose: false`. Name every feature. `list_commands {\"filter\": word}` \ +gives ids and one-line parameter docs (never unfiltered); `describe_command` gives one command's docs and schemas.\n\n\ +Routes: a plate, block, boss, standoff or hole is cheapest as a primitive with expression fields (`solid.box`, `solid.cylinder`, \ +`solid.hole`, `solid.fillet`, `solid.shell`, patterns and mirror), no sketch. Sketch when the profile is not a box or a circle. \ +Fillet outside edges before shelling, build inside features after the shell, then the holes and cuts through walls. Give a blind \ +hole or a hole through one wall of a hollow part its depth.\n\n\ +Parametric: a size or position that must follow a parameter is an expression in a feature field (`\"L/2 - 10\"`), a sketch \ +dimension to `\"origin\"` or an edge, or a pattern spacing; a plain number never follows a parameter. Every sketch must end at \ +dof 0 (`sketch.solve` reports it). A pick (`edges: [[x,y,z]]`, `faces: [[x,y,z]]`) is a number that snaps to the nearest edge \ +or face and follows it when parameters change.\n\n\ +Verify: `measure {}` after each stage and compare volume and bbox with your numbers; `ok: true` alone proves nothing: a cut \ +that leaves the volume unchanged has missed, a join that adds a body did not touch. Positions of holes and bosses come from \ +`body_topology` (cylinder origin, axis and radius), not from the volume or the centre of mass of a symmetric part. To check \ +that the design follows its parameters, `check_parameters {\"set\": {name: value}}` tries the values and puts the design \ +back (volume, errors, cylinder axes), or change them in one `batch` with `coalesce_parameters: true` (one rebuild), `measure`, \ +then set them back the same way before saving.\n\n\ +Fix, don't rebuild: a wrong number in a feature is changed with `timeline.edit` or `parameters.change` on its input \ +(`parameters.list {\"filter\":\"feature\"}` names them); `edit.checkpoint` and `edit.restore_checkpoint` go back exactly.\n\n\ +A call whose error starts `still running:` was not cancelled: don't send it again; `pending` waits for its result."; pub struct Server { backend: Box, diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 6c897500ec..aee23fe1cb 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -135,6 +135,7 @@ fn handshake_and_tools_list() { "measure", "body_topology", "set_parameter", + "check_parameters", "screenshot", "export", "undo", @@ -325,6 +326,115 @@ fn agent_builds_box_fillet_hole() { let _ = std::fs::remove_dir_all(&dir); } +/// A 60 × 40 × 10 plate with two Ø6 holes at x 15 and 45 and a Ø10 × 8 boss in the middle, all +/// on y 20 and along Z: three cylinders, the halves the kernel splits them into merged. +fn plate_with_holes_and_boss(s: &mut Server) { + let r = tool( + s, + "batch", + json!({"commands": [ + {"command": "sketch.create", "params": {"plane": "XY"}}, + {"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [60, 40]}}, + {"command": "sketch.finish"}, + {"command": "solid.extrude", "params": {"distance": 10}}, + {"command": "sketch.create", "params": {"plane": "XY"}}, + {"command": "sketch.circle.center", "params": {"center": [15, 20], "radius": 3}}, + {"command": "sketch.circle.center", "params": {"center": [45, 20], "radius": 3}}, + {"command": "sketch.finish"}, + {"command": "solid.extrude", "params": {"distance": 10, "operation": "cut"}}, + {"command": "solid.cylinder", "params": {"radius": 5, "height": 8, "base": [30, 20, 10], "operation": "join"}}, + ]}), + ); + assert_eq!(r["completed"], 10, "{r}"); +} + +/// `body_topology {faces: "cylinders"}` answers where the holes and bosses are in a few lines: +/// one entry per cylinder with its radius, axis line and extent, and no edges (#3380). +#[test] +fn body_topology_lists_merged_cylinders_with_their_extent() { + let mut s = server(); + plate_with_holes_and_boss(&mut s); + let full = tool(&mut s, "body_topology", json!({"body": "Body1"})); + let halves = full["faces"].as_array().unwrap().iter().filter(|f| f["cylinder"].is_object()).count(); + assert!(halves >= 3, "{full}"); + + let topo = tool(&mut s, "body_topology", json!({"body": "Body1", "faces": "cylinders"})); + assert_eq!(topo["body"], "Body1", "{topo}"); + assert!(topo.get("edges").is_none() && topo.get("faces").is_none(), "{topo}"); + let found = topo["cylinders"].as_array().unwrap(); + assert_eq!(found.len(), 3, "{topo}"); + assert_eq!(found.iter().map(|c| c["names"].as_array().unwrap().len()).sum::(), halves, "{topo}"); + assert!(topo.to_string().len() * 4 < full.to_string().len(), "{} vs {} bytes", topo.to_string().len(), full.to_string().len()); + // Each cylinder: (x of its axis, radius, the z its extent spans), its axis along Z. + let mut seen: Vec<(i64, i64, i64, i64)> = found + .iter() + .map(|c| { + let axis: Vec = c["axis"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect(); + let origin: Vec = c["origin"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect(); + assert!(axis[2].abs() > 1.0 - 1e-6, "{c}"); + assert!((origin[1] - 20.0).abs() < 1e-3, "{c}"); + let extent: Vec = c["extent"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect(); + let mut z: Vec = extent.iter().map(|t| origin[2] + t * axis[2]).collect(); + z.sort_by(|a, b| a.total_cmp(b)); + let mm = |v: f64| (v * 1000.0).round() as i64; + (mm(origin[0]), mm(c["radius"].as_f64().unwrap()), mm(z[0]), mm(z[1])) + }) + .collect(); + seen.sort_unstable(); + assert_eq!(seen, vec![(15000, 3000, 0, 10000), (30000, 5000, 10000, 18000), (45000, 3000, 0, 10000)], "{topo}"); + // Every merged half is a real face name, so the entry picks the face too. + let names: Vec<&str> = found.iter().flat_map(|c| c["names"].as_array().unwrap()).filter_map(Value::as_str).collect(); + let real: Vec<&str> = full["faces"].as_array().unwrap().iter().filter_map(|f| f["name"].as_str()).collect(); + assert!(!names.is_empty() && names.iter().all(|n| real.contains(n)), "{names:?} in {real:?}"); + + // The default is unchanged, and a value outside the enum is refused by argument checking. + let same = tool(&mut s, "body_topology", json!({"body": "Body1", "faces": "all"})); + assert_eq!(same, full); + let r = raw_tool(&mut s, "body_topology", json!({"body": "Body1", "faces": "planes"})); + assert_eq!(r["error"]["code"], -32602, "{r}"); + assert!(r["error"]["message"].as_str().unwrap().contains("cylinders"), "{r}"); +} + +/// `measure {summary: true}` keeps what a stage check needs, as a tool and as a batch step (#3380). +#[test] +fn measure_summary_keeps_only_the_check_fields() { + let mut s = server(); + plate_with_holes_and_boss(&mut s); + let full = tool(&mut s, "measure", json!({})); + let m = tool(&mut s, "measure", json!({"summary": true})); + let keys = |v: &Value| -> Vec { v.as_object().unwrap().keys().cloned().collect() }; + assert_eq!(keys(&m), ["bodies", "body_count", "total"], "{m}"); + assert_eq!(keys(&m["total"]), ["volume_mm3"], "{m}"); + assert_eq!(m["body_count"], 1, "{m}"); + let b = &m["bodies"][0]; + assert_eq!(keys(b), ["bbox", "closed", "faces", "name", "volume_mm3"], "{m}"); + assert_eq!(b["name"], "Body1"); + assert_eq!(b["closed"], true); + assert_eq!(b["faces"], full["bodies"][0]["faces"]); + assert_eq!(b["bbox"], full["bodies"][0]["bbox"]); + let expect = 60.0 * 40.0 * 10.0 - 2.0 * PI * 9.0 * 10.0 + PI * 25.0 * 8.0; + let v = m["total"]["volume_mm3"].as_f64().unwrap(); + assert_eq!(v, full["total"]["volume_mm3"].as_f64().unwrap(), "{m}"); + assert_eq!(b["volume_mm3"].as_f64().unwrap(), full["bodies"][0]["volume_mm3"].as_f64().unwrap(), "{m}"); + assert!(rel(v, expect) < 1e-3, "{v} vs {expect}"); + assert!(m.to_string().len() * 2 < full.to_string().len(), "{} vs {} bytes", m.to_string().len(), full.to_string().len()); + // One body by name, and the default unchanged. + let one = tool(&mut s, "measure", json!({"body": "Body1", "summary": true})); + assert_eq!(one, m); + assert_eq!(tool(&mut s, "measure", json!({"summary": false})), full); + + // As a batch step, the same reply. + let r = tool( + &mut s, + "batch", + json!({"commands": [{"command": "measure", "params": {"summary": true}}, {"command": "measure", "params": {"body": "Body1"}}]}), + ); + assert_eq!(r["completed"], 2, "{r}"); + assert_eq!(r["results"][0], m, "{r}"); + assert_eq!(r["results"][1], full, "{r}"); + assert_eq!(raw_tool(&mut s, "measure", json!({"summary": "yes"}))["error"]["code"], -32602); +} + /// `open` hands `up` and `names` to the STEP import, and the tool's schema refuses other values. #[test] fn open_stands_a_y_up_step_file_up() { @@ -451,6 +561,210 @@ fn set_parameter_takes_value_or_expression() { assert!(rel(volume(&mut s), 6000.0) < 1e-9); } +/// A box whose length is a parameter and a cylinder standing on it at `len / 2`, so a change +/// of `len` moves the cylinder's axis. +fn parametric_box_with_cylinder(s: &mut Server) { + tool(&mut *s, "set_parameter", json!({"name": "len", "value": "40 mm"})); + tool( + &mut *s, + "batch", + json!({"commands": [ + {"command": "solid.box", "params": {"length": "len", "width": 30, "height": 20, "corner": [0, 0, 0]}}, + {"command": "solid.cylinder", "params": {"radius": 5, "height": 10, "base": ["len / 2", 15, 20], "operation": "join"}}, + ]}), + ); +} + +fn box_with_cylinder_volume(len: f64) -> f64 { + len * 30.0 * 20.0 + PI * 25.0 * 10.0 +} + +/// `check_parameters` applies the values with one rebuild, reports the new volume, the +/// features' state and the moved cylinder axis (its two kernel halves as one cylinder), and +/// puts the design back: the parameter, the volume and the undo history are as before. +#[test] +fn check_parameters_tries_values_and_puts_the_design_back() { + let mut s = server(); + parametric_box_with_cylinder(&mut s); + assert!(rel(volume(&mut s), box_with_cylinder_volume(40.0)) < 5e-4); + let before = tool(&mut s, "inspect_design", json!({"measure": false, "sketches": false})); + let r = tool(&mut s, "check_parameters", json!({"set": {"len": "60 mm"}})); + assert_eq!(r["restored"], true, "{r}"); + assert_eq!(r["values"]["len"], 60.0, "{r}"); + assert!(r["recomputed"].as_u64().unwrap() >= 1, "{r}"); + let v = r["measure"]["total"]["volume_mm3"].as_f64().unwrap(); + assert!(rel(v, box_with_cylinder_volume(60.0)) < 5e-4, "{v}"); + assert_eq!(r["measure"]["body_count"], 1, "{r}"); + assert!(r["measure"]["bodies"][0]["faces"].as_u64().unwrap() >= 8, "{r}"); + assert_eq!((&r["errors"], &r["warnings"], &r["parameter_errors"]), (&json!([]), &json!([]), &json!([])), "{r}"); + let cyl = r["cylinders"].as_array().unwrap(); + assert_eq!(cyl.len(), 1, "one cylinder, halves merged: {r}"); + assert_eq!(cyl[0]["body"], "Body1", "{r}"); + assert!((cyl[0]["radius"].as_f64().unwrap() - 5.0).abs() < 1e-6, "{r}"); + let origin: Vec = cyl[0]["origin"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect(); + let axis: Vec = cyl[0]["axis"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect(); + assert!((origin[0] - 30.0).abs() < 1e-6 && (origin[1] - 15.0).abs() < 1e-6 && origin[2].abs() < 1e-6, "axis moved to len / 2 = 30: {r}"); + assert!(axis[0].abs() < 1e-9 && axis[1].abs() < 1e-9 && (axis[2] - 1.0).abs() < 1e-9, "{r}"); + assert!(!cyl[0]["faces"].as_array().unwrap().is_empty(), "{r}"); + // The halves' areas add up to the cylinder's (measured on its mesh). + assert!(rel(cyl[0]["area"].as_f64().unwrap(), 2.0 * PI * 5.0 * 10.0) < 1e-2, "{r}"); + // Back as it was: the value, the volume and the undo history (a checkpoint restores it). + assert!(rel(volume(&mut s), box_with_cylinder_volume(40.0)) < 5e-4); + let after = tool(&mut s, "inspect_design", json!({"measure": false, "sketches": false})); + assert!(after["params"].as_array().unwrap().iter().any(|p| p["name"] == "len" && p["value"] == 40.0 && p["expression"] == "40 mm"), "{after}"); + assert_eq!(after["undo"], before["undo"], "undo depth: {after}"); + assert_eq!(after["redo"], before["redo"], "{after}"); + // Only what was asked for. + let r = tool(&mut s, "check_parameters", json!({"set": {"len": 50}, "report": ["measure"]})); + assert!(rel(r["measure"]["total"]["volume_mm3"].as_f64().unwrap(), box_with_cylinder_volume(50.0)) < 5e-4, "{r}"); + assert!(r.get("cylinders").is_none() && r.get("errors").is_none(), "{r}"); + assert_eq!(r["restored"], true, "{r}"); + assert!(rel(volume(&mut s), box_with_cylinder_volume(40.0)) < 5e-4); + // A value that breaks a feature is reported, not kept. + let r = tool(&mut s, "check_parameters", json!({"set": {"len": 0}, "report": ["errors"]})); + assert!(!r["errors"].as_array().unwrap().is_empty(), "{r}"); + assert_eq!(r["restored"], true, "{r}"); + assert!(rel(volume(&mut s), box_with_cylinder_volume(40.0)) < 5e-4); + let d = tool(&mut s, "inspect_design", json!({"measure": false, "sketches": false})); + assert!(d["timeline"].as_array().unwrap().iter().all(|f| f["error"].is_null()), "{d}"); +} + +/// An unknown parameter name, a bad expression or a bad report list is an error that leaves the +/// design untouched; `set` that is not an object is refused by argument checking; the tool is +/// annotated as an edit (it changes the design and puts it back) and is not a batch step. +#[test] +fn check_parameters_refuses_bad_input_and_changes_nothing() { + let mut s = server(); + parametric_box_with_cylinder(&mut s); + let before = tool(&mut s, "inspect_design", json!({"measure": false, "sketches": false})); + let r = raw_tool(&mut s, "check_parameters", json!({"set": {"nope": 5}})); + assert_eq!(r["result"]["isError"], json!(true), "{r}"); + let text = r["result"]["content"][0]["text"].as_str().unwrap(); + assert!(text.contains("`nope`") && text.contains("`len`"), "{text}"); + let d = tool(&mut s, "inspect_design", json!({"measure": false, "sketches": false})); + assert_eq!(d["undo"], before["undo"], "{d}"); + assert!(d["params"].as_array().unwrap().iter().all(|p| p["name"] != "nope"), "{d}"); + for args in [json!({"set": "len"}), json!({"set": [["len", 5]]}), json!({"set": 5}), json!({"set": {"len": 5}, "report": "measure"}), json!({})] { + let r = raw_tool(&mut s, "check_parameters", args.clone()); + assert_eq!(r["error"]["code"], -32602, "{args}: {r}"); + } + for (args, hint) in [ + (json!({"set": {}}), "empty"), + (json!({"set": {"len": true}}), "`set.len`"), + (json!({"set": {"len": null}}), "`set.len`"), + (json!({"set": {"": 5}}), "empty"), + (json!({"set": {"len": 5}, "report": ["volume"]}), "measure, cylinders, errors"), + (json!({"set": {"len": "1 +"}}), "len"), + (json!({"set": {"len": "a = 5"}}), "len"), + ] { + let r = raw_tool(&mut s, "check_parameters", args.clone()); + assert_eq!(r["result"]["isError"], json!(true), "{args}: {r}"); + let text = r["result"]["content"][0]["text"].as_str().unwrap_or_default(); + assert!(text.contains(hint), "{args}: {text}"); + } + assert!(rel(volume(&mut s), box_with_cylinder_volume(40.0)) < 5e-4); + // Untouched: the undo depth and the parameter (the engine's `revision` moves even for a + // refused change, so it is not what tells). + let d = tool(&mut s, "inspect_design", json!({"measure": false, "sketches": false})); + assert_eq!(d["undo"], before["undo"], "{d}"); + assert!(d["params"].as_array().unwrap().iter().any(|p| p["name"] == "len" && p["expression"] == "40 mm"), "{d}"); + // Not a batch step: refused before anything runs. + let r = raw_tool(&mut s, "batch", json!({"commands": [{"command": "check_parameters", "params": {"set": {"len": 5}}}]})); + let body: Value = serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap(); + assert_eq!(body["completed"], 0, "{body}"); + assert!(body["failed"]["error"].as_str().unwrap().contains("check_parameters"), "{body}"); + let def = tool_definitions().into_iter().find(|d| d["name"] == "check_parameters").unwrap(); + assert_eq!(def["inputSchema"]["required"], json!(["set"]), "{def}"); + assert_eq!(def["annotations"]["readOnlyHint"], false, "{def}"); + assert_eq!(def["annotations"]["destructiveHint"], false, "{def}"); +} + +/// A backend answering from a closure: what `check_parameters` sends, and what it does when an +/// app has no `parameters.change {changes}` or cannot restore the checkpoint. +struct Answers { + calls: Vec<(String, Value)>, + answer: Box Result>, +} + +impl crate::backend::Backend for Answers { + fn call(&mut self, method: &str, params: Value) -> Result { + self.calls.push((method.to_string(), params.clone())); + (self.answer)(method, ¶ms) + } + fn has_ui(&self) -> bool { + true + } + fn describe(&self) -> String { + "answers".into() + } +} + +/// An app without `parameters.change {changes}` gets one change per name; one that cannot +/// restore the checkpoint makes the call an error that says the values are still in. +#[test] +fn check_parameters_falls_back_per_name_and_says_when_the_restore_failed() { + let app = |restore_ok: bool| Answers { + calls: Vec::new(), + answer: Box::new(move |method, p| { + let command = p["command"].as_str().unwrap_or_default(); + match (method, command) { + ("engine.execute", "parameters.list") => Err("unknown command `parameters.list`".into()), + ("engine.execute", "edit.checkpoint") => Ok(json!({"checkpoint": 3})), + ("engine.execute", "edit.restore_checkpoint") if restore_ok => Ok(json!({"checkpoint": 3, "restored": true})), + ("engine.execute", "edit.restore_checkpoint") => Err("busy: request 9 is still running".into()), + ("engine.execute", "parameters.change") if p["params"].get("changes").is_some() => { + Err("parameters.change: `name` is required".into()) + } + ("engine.execute", "parameters.change") => Ok(json!({"name": p["params"]["name"], "value": 7.0, "recomputed": 1})), + ("engine.execute", "inspect.measure") => Ok(json!({"body_count": 1, "bodies": [], "total": {"volume_mm3": 12.5}})), + ("document.inspect", _) => { + Ok(json!({"timeline": [{"id": 1, "name": "Box", "error": null, "warning": "thin"}], "params": [], "bodies": [{"name": "B"}]})) + } + ("engine.execute", "model.faces") => Ok(json!({"faces": [ + {"index": 0, "name": "side", "area": 1.0, "cylinder": {"origin": [0, 0, 5], "axis": [0, 0, -1], "radius": 2}}, + {"index": 1, "name": "side", "area": 1.0, "cylinder": {"origin": [0, 0, -3], "axis": [0, 0, 1], "radius": 2}}, + {"index": 2, "name": "hole", "area": 1.0, "cylinder": {"origin": [9, 0, 0], "axis": [0, 0, 1], "radius": 2}}, + {"index": 3, "name": "top", "area": 4.0, "plane_normal": [0, 0, 1]}, + ]})), + (m, c) => Err(format!("unknown method `{m}` ({c})")), + } + }), + }; + let mut b = app(true); + let r = crate::tools::call_tool(&mut b, "check_parameters", &json!({"set": {"a": "1 mm", "b": 2}})); + assert_ne!(r["isError"], json!(true), "{r}"); + let body: Value = serde_json::from_str(r["content"][0]["text"].as_str().unwrap()).unwrap(); + assert_eq!(body["restored"], true, "{body}"); + assert_eq!(body["values"], json!({"a": 7.0, "b": 7.0}), "{body}"); + assert_eq!(body["recomputed"], 2, "{body}"); + assert_eq!(body["measure"]["total"]["volume_mm3"], 12.5, "{body}"); + assert_eq!(body["warnings"], json!([{"feature": 1, "name": "Box", "warning": "thin"}]), "{body}"); + assert_eq!(body["errors"], json!([]), "{body}"); + // Two halves on one axis, reported with opposite directions and different axis points, are + // one cylinder; the hole next to it is another. + let cyl = body["cylinders"].as_array().unwrap(); + assert_eq!(cyl.len(), 2, "{body}"); + assert_eq!((&cyl[0]["faces"], &cyl[0]["origin"], &cyl[0]["axis"]), (&json!([0, 1]), &json!([0.0, 0.0, 0.0]), &json!([0.0, 0.0, 1.0])), "{body}"); + assert_eq!((&cyl[1]["faces"], &cyl[1]["origin"], &cyl[1]["name"]), (&json!([2]), &json!([9.0, 0.0, 0.0]), &json!("hole")), "{body}"); + let changes: Vec<&Value> = b.calls.iter().filter(|(_, p)| p["command"] == "parameters.change").map(|(_, p)| &p["params"]).collect(); + assert_eq!(changes.len(), 3, "one combined change, then one per name: {changes:?}"); + assert_eq!(changes[0], &json!({"changes": {"a": "1 mm", "b": 2}}), "{changes:?}"); + assert_eq!((changes[1]["name"].as_str(), changes[2]["name"].as_str()), (Some("a"), Some("b")), "{changes:?}"); + let last = b.calls.last().unwrap(); + assert_eq!(last.1["command"], "edit.restore_checkpoint", "the restore is the last call: {last:?}"); + assert_eq!(last.1["params"]["checkpoint"], 3, "{last:?}"); + + // The restore fails: an error, loudly, with everything that was read. + let mut b = app(false); + let r = crate::tools::call_tool(&mut b, "check_parameters", &json!({"set": {"a": "1 mm"}, "report": ["measure"]})); + assert_eq!(r["isError"], json!(true), "{r}"); + let body: Value = serde_json::from_str(r["content"][0]["text"].as_str().unwrap()).unwrap(); + assert_eq!(body["restored"], false, "{body}"); + assert!(body["restore_error"].as_str().unwrap().contains("still running"), "{body}"); + assert!(body["note"].as_str().unwrap().contains("STILL HOLDS"), "{body}"); + assert_eq!(body["measure"]["total"]["volume_mm3"], 12.5, "{body}"); +} + /// Design properties (#1015) are set through `execute` and come back in `inspect_design`. #[test] fn design_properties_show_in_inspect_design() { @@ -514,6 +828,23 @@ fn batch_refuses_an_unknown_command_before_the_first_step() { assert_eq!(tool(&mut s, "inspect_design", json!({"measure": false}))["undo"], 0); } +/// A step with its parameters under another key (`args`) refuses the batch before its first +/// step, naming the key, instead of running the command without them. +#[test] +fn batch_refuses_a_step_with_an_unknown_key() { + let mut s = server(); + let steps = json!([ + {"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10}}, + {"command": "sketch.create", "args": {"plane": "XY"}}, + ]); + let r = raw_tool(&mut s, "batch", json!({"commands": steps})); + assert_eq!(r["result"]["isError"], true, "{r}"); + let body: Value = serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap(); + assert_eq!((body["completed"].clone(), body["failed"]["index"].clone()), (json!(0), json!(1)), "{body}"); + assert!(body["failed"]["error"].as_str().is_some_and(|e| e.contains("unknown key `args`") && e.contains("`params`")), "{body}"); + assert_eq!(tool(&mut s, "measure", json!({}))["body_count"], 0, "no step ran"); +} + /// #5822: with `coalesce_parameters` a batch of parameter edits, one per step, rebuilds the /// design once for the run, and every step still has its own result. A run with an edit /// `changes` can't make (a new parameter) goes step by step, and a refused edit is still named @@ -640,6 +971,216 @@ fn a_failed_batch_with_file_new_restores_the_original_design() { assert_eq!(d["name"], name, "{d}"); } +/// `expect` on a batch: the volume, body count and bounding box the agent computed beforehand +/// are measured and compared by the server once the batch went through, so no separate +/// `measure` call and no comparison by hand follow every stage. +#[test] +fn a_batch_with_a_matching_expect_reports_the_expectation_met() { + let mut s = server(); + let r = tool( + &mut s, + "batch", + json!({ + "commands": [ + {"command": "solid.box", "params": {"length": 40, "width": 30, "height": 20, "corner": [0, 0, 0]}}, + {"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10, "corner": [15, 10, 5], "operation": "cut"}}, + ], + "expect": {"volume_mm3": 23000, "bodies": 1, "bbox": {"min": [0, 0, 0], "max": [40, 30, 20]}}, + }), + ); + assert_eq!(r["completed"], 2, "{r}"); + let e = &r["expectation"]; + assert_eq!(e["ok"], true, "{r}"); + assert_eq!(e["mismatch"], json!([]), "{r}"); + assert_eq!(e["measured"]["bodies"], 1, "{r}"); + assert!(rel(e["measured"]["volume_mm3"].as_f64().unwrap(), 23000.0) < 1e-6, "{r}"); + assert!(rel(e["measured"]["bbox"]["max"][0].as_f64().unwrap(), 40.0) < 1e-6, "{r}"); + assert_eq!( + e["expected"], + json!({"volume_mm3": 23000.0, "tol": 0.002, "bodies": 1, "bbox": {"min": [0.0, 0.0, 0.0], "max": [40.0, 30.0, 20.0]}}), + "{r}" + ); + // Without `expect` the reply is as before. + tool(&mut s, "new_design", json!({})); + let box40 = json!({"command": "solid.box", "params": {"length": 40, "width": 30, "height": 20, "corner": [0, 0, 0]}}); + let r = tool(&mut s, "batch", json!({"commands": [box40]})); + assert!(r.get("expectation").is_none(), "{r}"); + // `expect` is the tool's argument, not a step key: a step carrying one is refused, as any step + // with a stray key is, saying where `expect` goes, instead of running with nothing checked. + let sphere = json!({"command": "solid.sphere", "params": {"radius": 5, "center": [100, 0, 0]}, "expect": {"bodies": 7}}); + let r = raw_tool(&mut s, "batch", json!({"commands": [sphere]})); + assert_eq!(r["result"]["isError"], true, "{r}"); + let body: Value = serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap(); + assert_eq!((body["completed"].clone(), body["failed"]["index"].clone()), (json!(0), json!(0)), "{body}"); + assert!(body["failed"]["error"].as_str().is_some_and(|e| e.contains("`expect` is the batch's argument")), "{body}"); + assert_eq!(tool(&mut s, "measure", json!({}))["body_count"], 1, "the sphere did not run"); +} + +/// A volume the design does not match flags the reply as an error that names the mismatch +/// first, and keeps the design as built: the agent decides what to do with it. +#[test] +fn a_batch_with_a_wrong_expected_volume_is_flagged_and_the_design_kept() { + let mut s = server(); + let box40 = json!({"command": "solid.box", "params": {"length": 40, "width": 30, "height": 20, "corner": [0, 0, 0]}}); + let r = raw_tool(&mut s, "batch", json!({"commands": [box40], "expect": {"volume_mm3": 20000}})); + assert_eq!(r["result"]["isError"], true, "{r}"); + let content = r["result"]["content"].as_array().unwrap(); + let first = content[0]["text"].as_str().unwrap(); + assert!(first.starts_with("expectation not met: volume: measured 24000, expected 20000 (+20.00 %)."), "{first}"); + assert!(first.contains("kept as built"), "{first}"); + let body: Value = serde_json::from_str(content[1]["text"].as_str().unwrap()).unwrap(); + assert_eq!(body["ok"], true, "the batch itself went through: {body}"); + assert_eq!(body["completed"], 1, "{body}"); + assert_eq!(body["results"][0]["created_bodies"], json!(["Body1"]), "{body}"); + assert_eq!(body["expectation"]["ok"], false, "{body}"); + assert_eq!(body["expectation"]["mismatch"], json!(["volume: measured 24000, expected 20000 (+20.00 %)"]), "{body}"); + assert_eq!(body["expectation"]["expected"], json!({"volume_mm3": 20000.0, "tol": 0.002}), "{body}"); + // The design is kept as built, undoably. + assert_eq!(tool(&mut s, "measure", json!({}))["body_count"], 1); + assert!(rel(volume(&mut s), 24000.0) < 1e-9); + assert_eq!(tool(&mut s, "inspect_design", json!({"measure": false}))["undo"], 1); + // `tol` is relative to the expected volume: 0.5 % off passes at 1 %, not at the default 0.2 %. + let cube = json!({"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10, "corner": [100, 0, 0]}}); + let r = tool(&mut s, "batch", json!({"commands": [cube], "expect": {"volume_mm3": 25000.0 * 1.005, "tol": 0.01}})); + assert_eq!(r["expectation"]["ok"], true, "{r}"); + let r = raw_tool(&mut s, "batch", json!({"commands": [{"command": "undo"}], "expect": {"volume_mm3": 24000.0 * 1.005}})); + assert_eq!(r["result"]["isError"], true, "{r}"); + assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("volume: measured 24000, expected 24120 (-0.50 %)"), "{r}"); +} + +/// A wrong body count and a wrong bounding box are each named, with the coordinates that are +/// off; a bounding box within 0.05 mm matches. +#[test] +fn expect_names_body_count_and_bounding_box_mismatches() { + let mut s = server(); + let box40 = json!({"command": "solid.box", "params": {"length": 40, "width": 30, "height": 20, "corner": [0, 0, 0]}}); + let cube = json!({"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10, "corner": [100, -5, -5]}}); + let r = raw_tool( + &mut s, + "batch", + json!({"commands": [box40, cube], "expect": {"volume_mm3": 25000, "bodies": 1, "bbox": {"min": [0, 0, 0], "max": [40, 30, 20]}}}), + ); + assert_eq!(r["result"]["isError"], true, "{r}"); + let body: Value = serde_json::from_str(r["result"]["content"][1]["text"].as_str().unwrap()).unwrap(); + let mismatch = body["expectation"]["mismatch"].as_array().unwrap(); + assert_eq!(mismatch.len(), 2, "the volume matches and is not named: {body}"); + assert_eq!(mismatch[0], "bodies: measured 2, expected 1", "{body}"); + let bbox = mismatch[1].as_str().unwrap(); + assert!(bbox.starts_with("bbox: measured min [0, -5, -5] max [110, 30, 20], expected min [0, 0, 0] max [40, 30, 20] ("), "{bbox}"); + assert!(bbox.contains("min y off by -5 mm") && bbox.contains("min z off by -5 mm") && bbox.contains("max x off by 70 mm"), "{bbox}"); + assert!(!bbox.contains("max y") && !bbox.contains("max z") && !bbox.contains("min x"), "{bbox}"); + assert_eq!(body["expectation"]["measured"]["bodies"], 2, "{body}"); + assert!(rel(body["expectation"]["measured"]["bbox"]["max"][0].as_f64().unwrap(), 110.0) < 1e-6, "{body}"); + assert_eq!(tool(&mut s, "measure", json!({}))["body_count"], 2, "the design is kept"); + // Within 0.05 mm the box matches; past it the coordinate is named. + tool(&mut s, "undo", json!({})); + let r = tool(&mut s, "batch", json!({"commands": [{"command": "redo"}], "expect": {"bbox": {"min": [0, -5, -5.04], "max": [110.04, 30, 20]}}})); + assert_eq!(r["expectation"]["ok"], true, "{r}"); + let r = raw_tool(&mut s, "batch", json!({"commands": [{"command": "undo"}], "expect": {"bbox": {"min": [0, 0, 0], "max": [40.1, 30, 20]}}})); + assert_eq!(r["result"]["isError"], true, "{r}"); + assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("(max x off by -0.1 mm)"), "{r}"); + // No body at all: the box can't be measured, and the count says so. + tool(&mut s, "new_design", json!({})); + let r = raw_tool( + &mut s, + "batch", + json!({"commands": [{"command": "parameters.add", "params": {"name": "w", "expression": "1 mm"}}], "expect": {"bodies": 1, "bbox": {"min": [0, 0, 0], "max": [1, 1, 1]}}}), + ); + assert_eq!(r["result"]["isError"], true, "{r}"); + let body: Value = serde_json::from_str(r["result"]["content"][1]["text"].as_str().unwrap()).unwrap(); + assert_eq!( + body["expectation"]["mismatch"], + json!(["bodies: measured 0, expected 1", "bbox: no body to measure, expected min [0, 0, 0] max [1, 1, 1]"]), + "{body}" + ); + assert!(body["expectation"]["measured"]["bbox"].is_null(), "{body}"); +} + +/// An `expect` that is not what the schema says is refused before anything runs: a typo never +/// builds a design that then goes unchecked. +#[test] +fn a_malformed_expect_is_refused_before_anything_runs() { + let mut s = server(); + let step = json!({"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10}}); + for name in ["execute", "batch"] { + let args = |expect: Value| { + let mut a = if name == "batch" { json!({"commands": [step]}) } else { step.clone() }; + a["expect"] = expect; + a + }; + // Not an object: the argument check, as for every tool argument. + for bad in [json!(5), json!("24000"), json!([24000]), json!(true)] { + let r = raw_tool(&mut s, name, args(bad.clone())); + assert_eq!(r["error"]["code"], -32602, "{name} {bad}: {r}"); + assert!(r["error"]["message"].as_str().unwrap().contains("`expect` must be of type"), "{r}"); + } + // An object the tool can't use: a tool error naming the key, and nothing was run. + for (bad, says) in [ + (json!({}), "nothing to check"), + (json!({"volume": 24000}), "unknown key `volume`"), + (json!({"volume_mm3": "24000"}), "`volume_mm3` must be a number"), + (json!({"volume_mm3": -1}), "`volume_mm3` must not be negative"), + (json!({"volume_mm3": 1, "tol": -0.1}), "`tol` must not be negative"), + (json!({"bodies": 1.5}), "`bodies` must be a whole number"), + (json!({"bodies": -1}), "`bodies` must be a whole number"), + (json!({"bbox": [0, 0, 0]}), "`bbox` must be"), + (json!({"bbox": {"min": [0, 0], "max": [1, 1, 1]}}), "`bbox.min` must be [x, y, z]"), + (json!({"bbox": {"min": [0, 0, 0], "max": [1, "1", 1]}}), "`bbox.max` must be [x, y, z]"), + (json!({"bbox": {"min": [2, 0, 0], "max": [1, 1, 1]}}), "`bbox.min` must not exceed"), + (json!({"bbox": {"min": [0, 0, 0], "max": [1, 1, 1], "mid": [0, 0, 0]}}), "unknown key `bbox.mid`"), + ] { + let r = raw_tool(&mut s, name, args(bad.clone())); + assert_eq!(r["result"]["isError"], true, "{name} {bad}: {r}"); + let text = r["result"]["content"][0]["text"].as_str().unwrap(); + assert!(text.starts_with("expect: ") && text.contains(says), "{name} {bad}: {text}"); + } + assert_eq!(tool(&mut s, "measure", json!({}))["body_count"], 0, "{name}: nothing ran"); + assert_eq!(tool(&mut s, "inspect_design", json!({"measure": false}))["undo"], 0, "{name}: nothing ran"); + } +} + +/// `execute` takes `expect` as `batch` does: met, the reply carries the expectation; not met, +/// the reply is an error naming it, and the command's result stays, as the design does. A +/// command that fails is the error it always was, with nothing measured. +#[test] +fn execute_checks_its_expect_like_batch() { + let mut s = server(); + let r = tool( + &mut s, + "execute", + json!({ + "command": "solid.box", "params": {"length": 40, "width": 30, "height": 20, "corner": [0, 0, 0]}, + "expect": {"volume_mm3": 24000, "bodies": 1, "bbox": {"min": [0, 0, 0], "max": [40, 30, 20]}}, + }), + ); + assert_eq!(r["created_bodies"], json!(["Body1"]), "{r}"); + assert_eq!(r["expectation"]["ok"], true, "{r}"); + assert_eq!(r["expectation"]["mismatch"], json!([]), "{r}"); + let r = raw_tool( + &mut s, + "execute", + json!({ + "command": "solid.box", "params": {"length": 10, "width": 10, "height": 10, "corner": [15, 10, 5], "operation": "cut"}, + "verbose": false, + "expect": {"volume_mm3": 24000, "bodies": 2}, + }), + ); + assert_eq!(r["result"]["isError"], true, "{r}"); + let content = r["result"]["content"].as_array().unwrap(); + let first = content[0]["text"].as_str().unwrap(); + assert!(first.starts_with("expectation not met: volume: measured 23000, expected 24000 (-4.17 %); bodies: measured 1, expected 2."), "{first}"); + let body: Value = serde_json::from_str(content[1]["text"].as_str().unwrap()).unwrap(); + assert_eq!(body["changed_bodies"], json!(["Body1"]), "the command's result stays: {body}"); + assert!(body.get("bodies").is_none(), "compact, as asked: {body}"); + assert_eq!(body["expectation"]["measured"]["bodies"], 1, "{body}"); + assert!(rel(volume(&mut s), 23000.0) < 1e-9, "the cut is kept"); + let r = raw_tool(&mut s, "execute", json!({"command": "solid.extrude", "params": {"distance": 10}, "expect": {"bodies": 1}})); + assert_eq!(r["result"]["isError"], true, "{r}"); + assert_eq!(r["result"]["content"].as_array().map(Vec::len), Some(1), "{r}"); + assert!(!r["result"]["content"][0]["text"].as_str().unwrap().contains("expectation"), "{r}"); + assert!(rel(volume(&mut s), 23000.0) < 1e-9); +} + #[test] fn helper_sketches_can_be_hidden() { let mut s = server(); @@ -1508,6 +2049,100 @@ fn skill_reference_example_builds_as_documented() { holes_at(&mut s, 52.0); } +/// The server instructions' recipe (primitives with expression fields, fillet before the shell, +/// inside features after it, a blind hole with its depth, picks by world point, parameters +/// checked in one coalesced batch and set back) builds the part it describes, with the volume +/// worked out by hand, and follows its parameters, so the instructions and the engine stay in +/// step whatever the kernel does. +#[test] +fn instructions_recipe_builds_a_parametric_shelled_box() { + // A box L × W × H with its four vertical edges rounded r, shelled from the top with wall t + // (the inner corners are r - t), a boss of radius R from the floor to the rim, and a blind + // hole Ø d, depth hd, down the boss. + let (fr, t, big_r, d, hd) = (5.0, 2.0, 6.0, 5.0, 10.0); + let expected = |l: f64, w: f64, h: f64| { + let corner = 4.0 * (1.0 - PI / 4.0); + let outer = l * w * h - corner * fr * fr * h; + let cavity = (l - 2.0 * t) * (w - 2.0 * t) * (h - t) - corner * (fr - t) * (fr - t) * (h - t); + let boss = PI * big_r * big_r * (h - t); + let hole = PI * (d / 2.0) * (d / 2.0) * hd; + outer - cavity + boss - hole + }; + let mut s = server(); + let r = tool( + &mut s, + "batch", + json!({"verbose": false, "commands": [ + {"command": "parameters.add", "params": {"name": "L", "expression": "60 mm"}}, + {"command": "parameters.add", "params": {"name": "W", "expression": "40 mm"}}, + {"command": "parameters.add", "params": {"name": "H", "expression": "30 mm"}}, + {"command": "parameters.add", "params": {"name": "wall", "expression": "2 mm"}}, + {"command": "solid.box", "params": {"length": "L", "width": "W", "height": "H", "corner": [0, 0, 0], "name": "Block", "body_name": "Case"}}, + {"command": "solid.fillet", "params": {"edges": [[0, 0, 15], [60, 0, 15], [60, 40, 15], [0, 40, 15]], "radius": fr, "name": "Corners"}}, + {"command": "solid.shell", "params": {"faces": [[30, 20, 30]], "thickness": "wall", "name": "Hollow"}}, + {"command": "solid.cylinder", "params": {"radius": big_r, "height": "H - wall", "base": ["L/2", "W/2", "wall"], "operation": "join", "name": "Boss"}}, + {"command": "solid.hole", "params": {"position": ["L/2", "W/2", "H"], "diameter": d, "depth": hd, "name": "Pilot"}}, + {"command": "measure", "params": {}}, + ]}), + ); + let results = r["results"].as_array().unwrap(); + assert_eq!(results.len(), 10, "{r}"); + let m = &results[9]; + assert_eq!(m["bodies"].as_array().map(Vec::len), Some(1), "{m}"); + assert!(rel(m["total"]["volume_mm3"].as_f64().unwrap(), expected(60.0, 40.0, 30.0)) < 1e-3, "{m}"); + let no_errors = |s: &mut Server| { + let d = tool(s, "inspect_design", json!({"measure": false, "sketches": false})); + let failed: Vec<&Value> = d["timeline"].as_array().unwrap().iter().filter(|f| !f["error"].is_null()).collect(); + assert!(failed.is_empty(), "{failed:?}"); + assert_eq!(d["timeline"].as_array().map(Vec::len), Some(5), "{d}"); + }; + no_errors(&mut s); + // The boss and the pilot hole are where the expressions put them. + let axes = |s: &mut Server, x: f64, y: f64| { + let topo = tool(s, "body_topology", json!({"body": "Case"})); + let cylinders: Vec<(f64, f64, f64)> = topo["faces"] + .as_array() + .unwrap() + .iter() + .filter_map(|f| f.get("cylinder")) + .map(|c| (c["origin"][0].as_f64().unwrap(), c["origin"][1].as_f64().unwrap(), c["radius"].as_f64().unwrap())) + .collect(); + for radius in [big_r, d / 2.0] { + assert!( + cylinders.iter().any(|&(cx, cy, cr)| (cx - x).abs() < 1e-6 && (cy - y).abs() < 1e-6 && (cr - radius).abs() < 1e-6), + "no cylinder r {radius} on ({x}, {y}): {cylinders:?}" + ); + } + }; + axes(&mut s, 30.0, 20.0); + // Two parameters changed in one coalesced batch (one rebuild per run), measured, set back. + let r = tool( + &mut s, + "batch", + json!({"verbose": false, "coalesce_parameters": true, "commands": [ + {"command": "parameters.change", "params": {"name": "L", "expression": "80 mm"}}, + {"command": "parameters.change", "params": {"name": "W", "expression": "50 mm"}}, + {"command": "measure", "params": {}}, + {"command": "parameters.change", "params": {"name": "L", "expression": "60 mm"}}, + {"command": "parameters.change", "params": {"name": "W", "expression": "40 mm"}}, + ]}), + ); + let results = r["results"].as_array().unwrap(); + assert_eq!(results.len(), 5, "{r}"); + assert_eq!(results[0]["coalesced"], 2, "{r}"); + assert_eq!(results[0]["errors"].as_array().map(Vec::len), Some(0), "{r}"); + let m = &results[2]; + assert_eq!(m["bodies"].as_array().map(Vec::len), Some(1), "{m}"); + assert!(rel(m["total"]["volume_mm3"].as_f64().unwrap(), expected(80.0, 50.0, 30.0)) < 1e-3, "{m}"); + assert_eq!(results[3]["coalesced"], 2, "{r}"); + assert_eq!(results[4]["errors"].as_array().map(Vec::len), Some(0), "{r}"); + let m = tool(&mut s, "measure", json!({})); + assert_eq!(m["bodies"].as_array().map(Vec::len), Some(1), "{m}"); + assert!(rel(m["total"]["volume_mm3"].as_f64().unwrap(), expected(60.0, 40.0, 30.0)) < 1e-3, "{m}"); + no_errors(&mut s); + axes(&mut s, 30.0, 20.0); +} + /// The skill reference's sketch-plane table: where sketch (10, 20) lands and which way +distance goes. #[test] fn sketch_plane_axes_match_the_skill_reference() { @@ -1633,18 +2268,29 @@ fn skills_name_only_real_tools_and_commands() { assert!(checked > 30, "only {checked} tool and command names found in the skills"); } -/// Every tool the server instructions name (a bare `snake_case` word in backticks) is a real tool. +/// Every tool and command the server instructions name is real: a backticked `snake_case` word +/// (bare or as a call, `measure {}`) is a tool, a dotted id (`solid.box`) a command in the +/// engine's registry. A rename or removal can't leave the instructions sending agents to names +/// the server no longer has. #[test] fn instructions_name_only_real_tools() { let mut s = server(); let init = call(&mut s, 1, "initialize", json!({"protocolVersion": PROTOCOL_VERSION, "capabilities": {}})); let text = init["result"]["instructions"].as_str().unwrap().to_owned(); let tools: Vec = tool_definitions().iter().map(|t| t["name"].as_str().unwrap().to_owned()).collect(); + let commands: Vec = + tool(&mut s, "list_commands", json!({})).as_array().unwrap().iter().filter_map(|c| c["id"].as_str()).map(str::to_owned).collect(); + let ident = |w: &str| !w.is_empty() && w.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '.'); + // A span is `name`, `name {}` or `name {"json":…}`; `edges: [[x,y,z]]`, `verbose: false` and + // `"L/2 - 10"` are fields and values, not calls. let named: Vec<&str> = - text.split('`').skip(1).step_by(2).filter(|w| !w.is_empty() && w.chars().all(|c| c.is_ascii_lowercase() || c == '_')).collect(); - assert!(named.contains(&"body_topology"), "{named:?}"); + text.split('`').skip(1).step_by(2).map(|span| span.split_once(" {").map_or(span, |(head, _)| head)).filter(|w| ident(w)).collect(); + for must in ["skill_reference", "batch", "measure", "body_topology", "pending", "solid.box", "parameters.change", "edit.restore_checkpoint"] { + assert!(named.contains(&must), "{named:?}"); + } for name in named { - assert!(tools.iter().any(|t| t == name), "the instructions name `{name}`, which is not a tool"); + let known = if name.contains('.') { commands.iter().any(|c| c == name) } else { tools.iter().any(|t| t == name) }; + assert!(known, "the instructions name `{name}`, which is neither a tool nor a command"); } } @@ -1726,6 +2372,28 @@ fn batch_reports_a_failed_engine_batch_like_before() { ); } +/// Bridged, `expect` measures the app's design with one `inspect.measure` after the one +/// `engine.batch` request, and a batch that failed is not measured. +#[test] +fn a_bridged_batch_measures_its_expect_after_the_one_batch_call() { + let steps = json!([{"command": "a.one"}]); + let reply = json!({"completed": 1, "results": [{"x": 1}], "undone": 0}); + let mut b = Recorder { calls: Vec::new(), batch: Ok(reply.clone()) }; + let r = crate::tools::call_tool(&mut b, "batch", &json!({"commands": steps, "expect": {"bodies": 1}})); + assert_eq!(methods(&b), ["engine.batch", "engine.execute"]); + assert_eq!(b.calls[1].1, json!({"command": "inspect.measure", "params": {}})); + // The recorder's measure reply has no bodies: the mismatch comes first, the batch's reply after it. + assert_eq!(r["isError"], json!(true), "{r}"); + assert!(r["content"][0]["text"].as_str().unwrap().starts_with("expectation not met: bodies: measured 0, expected 1."), "{r}"); + let body: Value = serde_json::from_str(r["content"][1]["text"].as_str().unwrap()).unwrap(); + assert_eq!((body["ok"].clone(), body["completed"].clone(), body["expectation"]["ok"].clone()), (json!(true), json!(1), json!(false)), "{body}"); + let failed = json!({"completed": 0, "results": [], "failed": {"index": 0, "command": "a.one", "error": "boom"}, "undone": 0}); + let (b, body, is_error) = recorded_batch(Ok(failed), json!({"commands": steps, "expect": {"bodies": 1}})); + assert!(is_error); + assert_eq!(methods(&b), ["engine.batch"], "a failed batch is not measured"); + assert!(body.get("expectation").is_none(), "{body}"); +} + /// `rolled_back` tells whether the design went back, not whether a rollback was asked for /// (#3242): the app's `rolled_back`, or, for an app that predates that field, none of the /// completed steps undone means the design did not go back. @@ -1912,7 +2580,7 @@ fn tool_annotations_tell_the_whole_truth() { assert_eq!(hints(name), (true, false, false), "{name}"); } assert_eq!(hints("new_design"), (false, true, false)); - for name in ["set_parameter", "undo", "redo"] { + for name in ["set_parameter", "check_parameters", "undo", "redo"] { assert_eq!(hints(name), (false, false, false), "{name}"); } assert!(tools.iter().all(|t| t["annotations"]["readOnlyHint"].is_boolean()), "every tool is annotated"); diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 9a673b1f6c..cc266567f9 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -13,8 +13,14 @@ use crate::backend::{Backend, STILL_RUNNING}; const MAX_BATCH: usize = 10_000; /// Largest image returned by `screenshot`. const MAX_IMAGE_BYTES: usize = 32 << 20; +/// Relative tolerance on an expected volume (`expect.tol`) when the call gives none. +const EXPECT_VOLUME_TOL: f64 = 0.002; +/// Absolute tolerance on each expected bounding-box coordinate, mm. +const EXPECT_BBOX_TOL_MM: f64 = 0.05; const VIEWS: &[&str] = &["iso", "home", "front", "back", "top", "bottom", "left", "right", "fit"]; +/// What `check_parameters` can report. +const REPORTS: &[&str] = &["measure", "cylinders", "errors"]; fn obj(props: Value, required: &[&str]) -> Value { let mut o = json!({"type": "object", "properties": props, "additionalProperties": false}); @@ -58,6 +64,34 @@ fn string(desc: &str) -> Value { json!({"type": "string", "description": desc}) } +/// The `expect` argument of `execute` and `batch`: what the design should measure once the +/// call has gone through. +fn expect_schema() -> Value { + json!({ + "type": "object", + "description": "Check the design after the call against the numbers you computed: {volume_mm3?, tol? (relative, default 0.002), \ + bodies?, bbox?: {min: [x,y,z], max: [x,y,z]} (each coordinate within 0.05 mm)}. The reply gains `expectation`: \ + {ok, measured: {volume_mm3, bodies, bbox}, expected, mismatch: [\"volume: measured X, expected Y (+Z %)\", ...]}. \ + A mismatch is a tool error that names it first; the design is kept as built, so inspect it and fix it or carry on", + "properties": { + "volume_mm3": {"type": "number", "description": "Total volume of the closed bodies, mm³"}, + "tol": {"type": "number", "description": "Relative tolerance on the volume (default 0.002)"}, + "bodies": {"type": "integer", "description": "Number of bodies"}, + "bbox": { + "type": "object", + "description": "Bounding box of all bodies together, mm, each coordinate within 0.05 mm", + "properties": { + "min": {"type": "array", "items": {"type": "number"}, "minItems": 3, "maxItems": 3}, + "max": {"type": "array", "items": {"type": "number"}, "minItems": 3, "maxItems": 3}, + }, + "required": ["min", "max"], + "additionalProperties": false, + }, + }, + "additionalProperties": false, + }) +} + /// Every tool, with its JSON schema. The same list serves the headless and the bridged backend. pub fn tool_definitions() -> Vec { vec![ @@ -93,12 +127,14 @@ pub fn tool_definitions() -> Vec { after a delete (undo rewinds the counters with the design), usable right away \ in sketch.dimension, sketch.constraint.* and sketch.delete. Feature commands return the feature's id \ and name, every body (bodies) and the bodies the feature created, changed and removed (created_bodies, changed_bodies, removed_bodies). \ - view.pick {pixels: [[x,y]…]} answers the body, face, exact world point, normal and depth under pixels of a screenshot (read-only; docs/control-protocol.md).", + view.pick {pixels: [[x,y]…]} answers the body, face, exact world point, normal and depth under pixels of a screenshot (read-only; docs/control-protocol.md). \ + `expect` {volume_mm3, tol, bodies, bbox} checks the design after the command (as `batch` does) and flags a mismatch.", obj( json!({ "command": string("Command id (see list_commands)"), "params": {"type": "object", "description": "The command's parameters"}, "verbose": {"type": "boolean", "description": "false: omit the full feature body list and sketch ids repeated outside `created`; keep created_bodies, changed_bodies and removed_bodies (default true)"}, + "expect": expect_schema(), }), &["command"], ), @@ -117,7 +153,9 @@ pub fn tool_definitions() -> Vec { rollback: false), so the batch is all-or-nothing; the reply names the failing step (index from 0), its error, \ every result before it, whether the design really went back (rolled_back), the steps whose changes are in the \ design (applied) and those never sent (not_run). The usual way to build a part: sketch, draw, dimension, \ - finish, extrude, fillet… in one call.", + finish, extrude, fillet… in one call. Give `expect` {volume_mm3, tol, bodies, bbox} with the numbers you computed \ + beforehand: after the last step the design is measured and compared, so no separate measure call is needed; a \ + mismatch is flagged as an error naming it, and the design is kept as built.", obj( json!({ "commands": { @@ -130,6 +168,7 @@ pub fn tool_definitions() -> Vec { "timings": {"type": "boolean", "description": "Add `timings`: [{index, command, ms}] for every step run (the failing one included) and `total_ms` (default false)"}, "profile": {"type": "boolean", "description": "Like timings, and each step's `profile`: {boolean_ms, tessellate_ms, other_ms}, its time in kernel booleans, in tessellation and the rest (default false)"}, "verbose": {"type": "boolean", "description": "false: a compact reply. Feature steps leave out the full body list (created_bodies, changed_bodies and removed_bodies stay), sketch steps the ids they repeat outside `created` (default true)"}, + "expect": expect_schema(), }), &["commands"], ), @@ -161,11 +200,14 @@ pub fn tool_definitions() -> Vec { bodies, with totals. With `items` (one or two selections, as `execute` takes them: faces, edges, vertices, \ sketch curves, profiles): one item's length, area or position, a circle's, arc's or cylinder's `diameter` and \ `radius` (with `circumference`, or an arc's `arc_length` and `angle_deg`), a slot's `slot` sizes; two items' \ - `min` and `max` distance and, where one is round, `center_distance` (centre to centre, or from its axis).", + `min` and `max` distance and, where one is round, `center_distance` (centre to centre, or from its axis). \ + `summary: true` keeps one short object per body (name, volume_mm3, bbox, faces, closed) and the total volume: \ + enough to check a stage against the numbers you computed, at a fraction of the size.", obj( json!({ "body": string("Body name (default: all bodies)"), "items": {"type": "array", "items": {"type": "object"}, "description": "One or two selections to measure instead of bodies, e.g. {\"type\": \"face\", \"body\": \"Body1\", \"index\": 3, \"point\": [x,y,z]}"}, + "summary": {"type": "boolean", "description": "Per body only name, volume_mm3, bbox {min, max}, faces and closed, and total.volume_mm3 (default false: the full reply)"}, }), &[], ), @@ -176,8 +218,17 @@ pub fn tool_definitions() -> Vec { "List edges and faces", "The edges (index, midpoint, length, endpoints) and faces (index, name, area, centroid, plane normal, a `point` that lies \ on the face, and for a cylindrical face its `cylinder`: origin, axis, radius) of a body. Use an edge midpoint as the \ - [x,y,z] point that picks the edge for fillet and chamfer; a face's point or its name picks the face for the face commands.", - obj(json!({"body": string("Body name, e.g. \"Body1\"")}), &["body"]), + [x,y,z] point that picks the edge for fillet and chamfer; a face's point or its name picks the face for the face commands. \ + `faces: \"cylinders\"` lists only the cylindrical faces, as `cylinders`, with the halves the kernel splits one cylinder \ + into merged: {radius, origin, axis, extent, names}, `extent` the [min, max] distance along `axis` from `origin` that \ + the cylinder spans, and no edges. That is enough to check where every hole and boss is, at a fraction of the size.", + obj( + json!({ + "body": string("Body name, e.g. \"Body1\""), + "faces": {"type": "string", "enum": ["all", "cylinders"], "description": "\"all\" (default): every edge and face. \"cylinders\": only the cylindrical faces, merged, with their extent along the axis, and no edges"}, + }), + &["body"], + ), Effect::ReadOnly, ), tool( @@ -200,6 +251,34 @@ pub fn tool_definitions() -> Vec { ), Effect::Edits, ), + tool( + "check_parameters", + "Try parameter values", + "Try parameter values without keeping them: set every parameter in `set` with one rebuild, report what `report` \ + asks for, and put the design back exactly as it was (undo history included). `measure`: volume, bbox, bodies and \ + faces per body as the measure tool reports them; `errors`: the features that fail or warn and the parameters that \ + don't evaluate; `cylinders`: every cylindrical face as {body, radius, origin, axis, faces, name}, the halves the \ + kernel splits one cylinder into merged (origin is the axis point closest to the world origin). The reply ends with \ + `restored: true`; if the design could not be put back the call is an error with `restored: false` and the values \ + still applied. An unknown parameter name is an error and nothing changes. Replaces the loop set_parameter, \ + measure, body_topology, set_parameter back.", + obj( + json!({ + "set": { + "type": "object", + "description": "Values to try, by parameter name: a number (mm) or an expression (\"140 mm\", \"wall * 2\"), as set_parameter's `value`", + "additionalProperties": {"type": ["number", "string"]}, + }, + "report": { + "type": "array", + "items": {"type": "string", "enum": REPORTS}, + "description": "What to report: measure, cylinders, errors (default all three)", + }, + }), + &["set"], + ), + Effect::Edits, + ), tool( "screenshot", "Look at the design", @@ -418,6 +497,8 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> Value { let r = match name { "screenshot" => return screenshot(b, a), "batch" => return batch(b, a), + "check_parameters" => return check_parameters(b, a), + "execute" => return execute(b, a), _ => dispatch(b, name, a), }; match r { @@ -426,6 +507,213 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> Value { } } +/// `execute`: one command, then its `expect` checked against the design. +fn execute(b: &mut dyn Backend, a: &Map) -> Value { + let expect = match Expect::parse(a) { + Ok(e) => e, + Err(e) => return text_result(&Value::String(e), true), + }; + match dispatch(b, "execute", a) { + Ok(v) => with_expectation(b, v, expect), + Err(e) => text_result(&Value::String(e), true), + } +} + +/// What `execute` or `batch` was told the design would measure once the call went through +/// (`expect`): the numbers an agent computes before building, compared by the server instead +/// of by hand after a separate `measure` call. Volume and body count hold whatever kernel +/// builds the design. +struct Expect { + volume_mm3: Option, + tol: f64, + bodies: Option, + bbox: Option<([f64; 3], [f64; 3])>, +} + +impl Expect { + /// The tool's `expect` argument: `None` when it was not given. A malformed one is refused + /// before the call runs, so a typo never builds a design that then goes unchecked. + fn parse(a: &Map) -> Result, String> { + let Some(v) = a.get("expect").filter(|v| !v.is_null()) else { return Ok(None) }; + let Some(o) = v.as_object() else { return Err("expect: must be an object {volume_mm3?, tol?, bodies?, bbox?}".into()) }; + if let Some(k) = o.keys().find(|k| !["volume_mm3", "tol", "bodies", "bbox"].contains(&k.as_str())) { + let k: String = k.chars().take(64).collect(); + return Err(format!("expect: unknown key `{k}`; its keys are: volume_mm3, tol, bodies, bbox")); + } + let number = |k: &str| -> Result, String> { + match o.get(k) { + None | Some(Value::Null) => Ok(None), + Some(v) => v.as_f64().filter(|x| x.is_finite()).map(Some).ok_or_else(|| format!("expect: `{k}` must be a number")), + } + }; + let volume_mm3 = number("volume_mm3")?; + if volume_mm3.is_some_and(|v| v < 0.0) { + return Err("expect: `volume_mm3` must not be negative".into()); + } + let tol = number("tol")?.unwrap_or(EXPECT_VOLUME_TOL); + if tol < 0.0 { + return Err("expect: `tol` must not be negative".into()); + } + let bodies = match o.get("bodies") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_u64().ok_or("expect: `bodies` must be a whole number, 0 or more")?), + }; + let bbox = match o.get("bbox") { + None | Some(Value::Null) => None, + Some(v) => Some(Self::parse_bbox(v)?), + }; + if volume_mm3.is_none() && bodies.is_none() && bbox.is_none() { + return Err("expect: nothing to check; give volume_mm3, bodies or bbox".into()); + } + Ok(Some(Expect { volume_mm3, tol, bodies, bbox })) + } + + fn parse_bbox(v: &Value) -> Result<([f64; 3], [f64; 3]), String> { + let Some(o) = v.as_object() else { return Err("expect: `bbox` must be {min: [x,y,z], max: [x,y,z]} in mm".into()) }; + if let Some(k) = o.keys().find(|k| !["min", "max"].contains(&k.as_str())) { + let k: String = k.chars().take(64).collect(); + return Err(format!("expect: unknown key `bbox.{k}`; its keys are: min, max")); + } + let corner = |k: &str| -> Result<[f64; 3], String> { + let err = || format!("expect: `bbox.{k}` must be [x, y, z] in mm"); + let xyz = o.get(k).and_then(Value::as_array).filter(|a| a.len() == 3).ok_or_else(err)?; + let mut c = [0.0; 3]; + for (slot, x) in c.iter_mut().zip(xyz) { + *slot = x.as_f64().filter(|x| x.is_finite()).ok_or_else(err)?; + } + Ok(c) + }; + let (min, max) = (corner("min")?, corner("max")?); + if min.iter().zip(&max).any(|(lo, hi)| lo > hi) { + return Err("expect: `bbox.min` must not exceed `bbox.max`".into()); + } + Ok((min, max)) + } + + /// What was expected, for the reply. + fn expected(&self) -> Value { + let mut e = Map::new(); + if let Some(v) = self.volume_mm3 { + e.insert("volume_mm3".into(), json!(v)); + e.insert("tol".into(), json!(self.tol)); + } + if let Some(n) = self.bodies { + e.insert("bodies".into(), json!(n)); + } + if let Some((min, max)) = self.bbox { + e.insert("bbox".into(), json!({"min": min, "max": max})); + } + Value::Object(e) + } + + /// Measure the design (as the `measure` tool does) and compare: the reply's `expectation` + /// (`ok`, `measured`, `expected`, `mismatch`). + fn check(&self, b: &mut dyn Backend) -> Result { + let m = exec(b, "inspect.measure", json!({}))?; + let bodies = m["body_count"].as_u64().unwrap_or(0); + let volume = m["total"]["volume_mm3"].as_f64(); + let bbox = m["bodies"].as_array().into_iter().flatten().filter_map(|body| corners(&body["bbox"])).reduce(|(lo, hi), (min, max)| { + ([lo[0].min(min[0]), lo[1].min(min[1]), lo[2].min(min[2])], [hi[0].max(max[0]), hi[1].max(max[1]), hi[2].max(max[2])]) + }); + let mut mismatch: Vec = Vec::new(); + if let Some(e) = self.volume_mm3 { + match volume { + Some(v) if (v - e).abs() <= self.tol * e.abs() => {} + Some(v) => { + let pct = if e != 0.0 { format!(" ({:+.2} %)", (v - e) / e * 100.0) } else { String::new() }; + mismatch.push(format!("volume: measured {}, expected {}{pct}", num(v), num(e))); + } + None => mismatch.push(format!("volume: not measured (no closed body), expected {}", num(e))), + } + } + if let Some(e) = self.bodies + && bodies != e + { + mismatch.push(format!("bodies: measured {bodies}, expected {e}")); + } + if let Some((emin, emax)) = self.bbox { + let expected = format!("min {} max {}", xyz_text(emin), xyz_text(emax)); + match bbox { + Some((min, max)) => { + let off: Vec = [("min", min, emin), ("max", max, emax)] + .into_iter() + .flat_map(|(side, m, e)| { + ["x", "y", "z"] + .into_iter() + .zip(m) + .zip(e) + .filter(|&((_, m), e)| (m - e).abs() > EXPECT_BBOX_TOL_MM) + .map(move |((axis, m), e)| format!("{side} {axis} off by {} mm", num(m - e))) + }) + .collect(); + if !off.is_empty() { + mismatch.push(format!( + "bbox: measured min {} max {}, expected {expected} ({})", + xyz_text(min), + xyz_text(max), + off.join(", ") + )); + } + } + None => mismatch.push(format!("bbox: no body to measure, expected {expected}")), + } + } + Ok(json!({ + "ok": mismatch.is_empty(), + "measured": {"volume_mm3": volume, "bodies": bodies, "bbox": bbox.map(|(min, max)| json!({"min": min, "max": max}))}, + "expected": self.expected(), + "mismatch": mismatch, + })) + } +} + +/// `{min: [x,y,z], max: [x,y,z]}` as two corners. +fn corners(v: &Value) -> Option<([f64; 3], [f64; 3])> { + let xyz = |k: &str| -> Option<[f64; 3]> { + let a = v.get(k)?.as_array()?; + Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?, a.get(2)?.as_f64()?]) + }; + Some((xyz("min")?, xyz("max")?)) +} + +/// A number in a mismatch message: at most four decimals, no trailing zeros. +fn num(x: f64) -> String { + let r = (x * 1e4).round() / 1e4; + // `-0` for a coordinate a hair under zero would read as a sign error. + format!("{}", if r == 0.0 { 0.0 } else { r }) +} + +fn xyz_text(c: [f64; 3]) -> String { + format!("[{}, {}, {}]", num(c[0]), num(c[1]), num(c[2])) +} + +/// A reply that went through, with its `expectation` when `expect` was given. A mismatch is a +/// tool error that names it first; the design stays as built (the agent decides what to do). +fn with_expectation(b: &mut dyn Backend, mut reply: Value, expect: Option) -> Value { + let Some(expect) = expect else { return text_result(&reply, false) }; + let expectation = match expect.check(b) { + Ok(e) => e, + Err(e) => json!({"ok": false, "expected": expect.expected(), "mismatch": [format!("the design could not be measured: {e}")]}), + }; + let mismatch: Vec<&str> = expectation["mismatch"].as_array().into_iter().flatten().filter_map(Value::as_str).collect(); + let note = (expectation["ok"] != true).then(|| { + format!( + "expectation not met: {}. The design is kept as built: inspect it, then fix it (timeline.edit, undo) or carry on.", + mismatch.join("; ") + ) + }); + if !reply.is_object() { + reply = json!({"result": reply}); + } + reply["expectation"] = expectation; + let Some(note) = note else { return text_result(&reply, false) }; + let mut r = text_result(&reply, true); + if let Some(content) = r["content"].as_array_mut() { + content.insert(0, json!({"type": "text", "text": note})); + } + r +} + fn exec(b: &mut dyn Backend, command: &str, params: Value) -> Result { b.call("engine.execute", json!({"command": command, "params": params})).map_err(|error| command_error(command, error)) } @@ -540,13 +828,17 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Map) -> Result json!({"bodies": [body]}), None => json!({}), }; - exec(b, "inspect.measure", p) + let m = exec(b, "inspect.measure", p)?; + Ok(if a.get("summary").and_then(Value::as_bool).unwrap_or(false) { measure_summary(m) } else { m }) } "body_topology" => { let p = pick(a, &["body"]); let edges = exec(b, "model.edges", p.clone())?; let faces = exec(b, "model.faces", p)?; - Ok(json!({"body": a.get("body"), "edges": edges["edges"], "faces": faces["faces"]})) + Ok(match str_arg(a, "faces") { + Some("cylinders") => json!({"body": a.get("body"), "cylinders": topology_cylinders(&faces["faces"], &edges["edges"])}), + _ => json!({"body": a.get("body"), "edges": edges["edges"], "faces": faces["faces"]}), + }) } "set_parameter" => { let mut p = pick(a, &["name", "unit", "comment"]); @@ -603,18 +895,44 @@ fn pending(b: &mut dyn Backend) -> Result { Ok(json!({"pending": what, "done": false, "running_s": p.get("running_s"), "note": "still running: call pending again to keep waiting"})) } +/// How a tool step's result is cut down to what the tool itself would reply. +enum Narrow { + /// A `list_commands` step's catalog, filtered as the tool filters it. + Commands(String), + /// A `measure {summary: true}` step's reply, as `measure_summary` shapes it. + MeasureSummary, +} + +impl Narrow { + fn apply(&self, v: Value) -> Value { + match self { + Narrow::Commands(f) => filter_commands(v, Some(f)), + Narrow::MeasureSummary => measure_summary(v), + } + } +} + /// A `batch` step as the engine command it runs: an engine command as it is, or one of these /// tools' names (an agent writes the tool it knows) with that tool's arguments as its params. -/// `Ok((command, params, filter))`: `filter` narrows a `list_commands` step's catalog; an empty +/// `Ok((command, params, narrow))`: `narrow` cuts the step's result down as the tool would; an empty /// `command` means the step is an engine command already. -fn batch_step(step: &Value) -> Result<(String, Value, Option), String> { +fn batch_step(step: &Value) -> Result<(String, Value, Option), String> { let command = step.get("command").or_else(|| step.get("id")).and_then(Value::as_str).ok_or("missing `command`")?; + // Any other key would be dropped and the command run without its parameters, failing (if at + // all) with an error about a parameter that seems to be there: `args` for `params`. + if let Some(k) = step.as_object().into_iter().flat_map(Map::keys).find(|k| !matches!(k.as_str(), "command" | "id" | "params")) { + if k == "expect" { + return Err("unknown key `expect` in a step: `expect` is the batch's argument, checked after the last step".into()); + } + let k: String = k.chars().take(64).collect(); + return Err(format!("unknown key `{k}` in a step: a step is {{command, params}}, its parameters go in `params`")); + } let empty = Map::new(); let params = step.get("params").cloned().filter(|p| !p.is_null()).unwrap_or(json!({})); let a = params.as_object().unwrap_or(&empty); let mapped = |c: &str, p: Value| Ok((c.to_string(), p, None)); match command { - "list_commands" => Ok(("engine.commands".into(), json!({}), str_arg(a, "filter").map(str::to_string))), + "list_commands" => Ok(("engine.commands".into(), json!({}), str_arg(a, "filter").map(|f| Narrow::Commands(f.to_string())))), "set_parameter" => { let mut p = pick(a, &["name", "unit", "comment"]); p["expression"] = parameter_value(a)?; @@ -630,7 +948,11 @@ fn batch_step(step: &Value) -> Result<(String, Value, Option), String> { } "measure" => match a.get("items") { Some(items) => mapped("inspect.measure", json!({"items": items})), - None => mapped("inspect.measure", str_arg(a, "body").map_or(json!({}), |body| json!({"bodies": [body]}))), + None => { + let p = str_arg(a, "body").map_or(json!({}), |body| json!({"bodies": [body]})); + let summary = a.get("summary").and_then(Value::as_bool).unwrap_or(false); + Ok(("inspect.measure".into(), p, summary.then_some(Narrow::MeasureSummary))) + } }, "inspect_design" => mapped("document.inspect", json!({"measure": a.get("measure").and_then(Value::as_bool).unwrap_or(true)})), "undo" => mapped("edit.undo", json!({})), @@ -646,6 +968,7 @@ fn batch_step(step: &Value) -> Result<(String, Value, Option), String> { "body_topology" => Err("`body_topology` is a tool, not a command: in a batch use model.edges {body} and model.faces {body}".into()), "screenshot" => Err("`screenshot` is a tool, not a command: call it after the batch".into()), "batch" => Err("batches don't nest: put the steps in this batch".into()), + "check_parameters" => Err("`check_parameters` is a tool, not a command: call it on its own (it puts the design back itself)".into()), "pending" => Err("`pending` is a tool, not a command: call it on its own".into()), "running" => Err("`running` is a tool, not a command: call it on its own".into()), "skill_reference" => Err("`skill_reference` is a tool, not a command: call it on its own".into()), @@ -658,6 +981,7 @@ fn batch_step(step: &Value) -> Result<(String, Value, Option), String> { const TOOL_STEPS: &[&str] = &[ "batch", "body_topology", + "check_parameters", "crash_reports", "describe_command", "execute", @@ -748,13 +1072,26 @@ impl Undo { } /// `batch`: run steps in order, stop at the first failure and (by default) go back to the -/// design as it was before the batch. +/// design as it was before the batch. A batch that went through is then checked against its +/// `expect`. fn batch(b: &mut dyn Backend, a: &Map) -> Value { + let expect = match Expect::parse(a) { + Ok(e) => e, + Err(e) => return text_result(&Value::String(e), true), + }; + match batch_run(b, a) { + (reply, true) => text_result(&reply, true), + (reply, false) => with_expectation(b, reply, expect), + } +} + +/// The batch itself: its reply and whether it failed. +fn batch_run(b: &mut dyn Backend, a: &Map) -> (Value, bool) { let Some(steps) = a.get("commands").and_then(Value::as_array) else { - return text_result(&json!("`commands` must be an array of {command, params}"), true); + return (json!("`commands` must be an array of {command, params}"), true); }; if steps.len() > MAX_BATCH { - return text_result(&json!(format!("batch too long ({} steps, at most {MAX_BATCH})", steps.len())), true); + return (json!(format!("batch too long ({} steps, at most {MAX_BATCH})", steps.len())), true); } let rollback = a.get("rollback").and_then(Value::as_bool).unwrap_or(true); let verbose = a.get("verbose").and_then(Value::as_bool).unwrap_or(true); @@ -763,24 +1100,24 @@ fn batch(b: &mut dyn Backend, a: &Map) -> Value { // Steps that name a tool run the engine command it stands for. One that can't be a step // fails the batch before anything is sent. let mut mapped: Vec = Vec::with_capacity(steps.len()); - let mut filters: Vec<(usize, String)> = Vec::new(); + let mut narrows: Vec<(usize, Narrow)> = Vec::new(); for (i, step) in steps.iter().enumerate() { // The window goes to another design's tab for these, however the step names them: the // batch's later steps and its rollback would land in the wrong design. - let step_result = batch_step(step).and_then(|(command, params, filter)| { + let step_result = batch_step(step).and_then(|(command, params, narrow)| { let id = if command.is_empty() { step.get("command").or_else(|| step.get("id")).and_then(Value::as_str).unwrap_or("") } else { &command }; match solidcraft_engine::find_command(id).map(|spec| spec.id) { Some(c @ ("component.edit_in_place" | "component.finish_edit")) => { Err(format!("`{c}` goes to another design's tab: send it on its own, not in a batch")) } - _ => Ok((command, params, filter)), + _ => Ok((command, params, narrow)), } }); match step_result { Ok((command, _, _)) if command.is_empty() => mapped.push(step.clone()), - Ok((command, params, filter)) => { - if let Some(f) = filter { - filters.push((i, f)); + Ok((command, params, narrow)) => { + if let Some(n) = narrow { + narrows.push((i, n)); } mapped.push(json!({"command": command, "params": params})); } @@ -799,7 +1136,7 @@ fn batch(b: &mut dyn Backend, a: &Map) -> Value { "results": [], "note": "nothing was run", }); - return text_result(&reply, true); + return (reply, true); } } } @@ -818,11 +1155,12 @@ fn batch(b: &mut dyn Backend, a: &Map) -> Value { if let (Some(command), Some(failed)) = (refusal.command.as_deref(), reply.get_mut("failed").and_then(Value::as_object_mut)) { failed.insert("error".into(), Value::String(command_error(command, refusal.error.clone()))); } - return text_result(&reply, true); + return (reply, true); } - // A `list_commands` step's catalog, filtered as the tool filters it. - let narrow = |i: usize, v: Value| match filters.iter().find(|(j, _)| *j == i) { - Some((_, f)) => filter_commands(v, Some(f)), + // A tool step's result as the tool would reply it: a `list_commands` step's catalog filtered, + // a `measure {summary: true}` step's reply summarised. + let narrow = |i: usize, v: Value| match narrows.iter().find(|(j, _)| *j == i) { + Some((_, n)) => n.apply(v), None => v, }; // An app that runs batches itself answers the whole batch in one request, so one frame @@ -855,12 +1193,12 @@ fn batch(b: &mut dyn Backend, a: &Map) -> Value { // Never sent: no app to send it to. Or sent and still running (it was not cancelled): // the `pending` tool gets its result. Err(e) if e.starts_with(crate::backend::NOT_RUNNING) || e.starts_with(STILL_RUNNING) => { - return text_result(&Value::String(e), true); + return (Value::String(e), true); } // The app may have run it: sending the steps again would apply them twice. Err(e) => { let msg = format!("the app did not answer engine.batch ({e}); it may have run: inspect the design before sending it again"); - return text_result(&Value::String(msg), true); + return (Value::String(msg), true); } } } @@ -930,14 +1268,14 @@ fn batch(b: &mut dyn Backend, a: &Map) -> Value { "not_run": (i + 1..steps.len()).collect::>(), "results": results, }); - return text_result(&with_timings(reply, timed, rows, clock.ms()), true); + return (with_timings(reply, timed, rows, clock.ms()), true); } } } } start.finish(b); let reply = json!({"ok": true, "completed": results.len(), "results": results}); - text_result(&with_timings(reply, timed, rows, clock.ms()), false) + (with_timings(reply, timed, rows, clock.ms()), false) } /// `reply` with `timings` and `total_ms` when they were asked for. @@ -950,7 +1288,7 @@ fn with_timings(mut reply: Value, timed: bool, rows: Vec, total_ms: f64) } /// The `batch` reply from the app's `engine.batch` answer, shaped as the per-step path shapes it. -fn batch_reply(r: &Value, len: usize, rollback: bool, verbose: bool, narrow: &dyn Fn(usize, Value) -> Value) -> Value { +fn batch_reply(r: &Value, len: usize, rollback: bool, verbose: bool, narrow: &dyn Fn(usize, Value) -> Value) -> (Value, bool) { let results: Vec = r["results"] .as_array() .cloned() @@ -994,7 +1332,151 @@ fn batch_reply(r: &Value, len: usize, rollback: bool, verbose: bool, narrow: &dy } } } - text_result(&reply, error) + (reply, error) +} + +/// `measure {summary: true}`: an `inspect.measure` reply cut down to what checks a stage against +/// computed numbers: per body `name`, `volume_mm3`, `bbox`, `faces` and `closed` (a body that +/// could not be measured keeps its `error`), `body_count` and the total volume. An `items` +/// reply is small already and goes through as it is. +fn measure_summary(v: Value) -> Value { + let Some(o) = v.as_object() else { return v }; + if !o.contains_key("bodies") { + return v; + } + let bodies: Vec = o + .get("bodies") + .and_then(Value::as_array) + .into_iter() + .flatten() + .map(|b| { + let mut s = Map::new(); + let keys: &[&str] = if b.get("error").is_some() { &["name", "error"] } else { &["name", "volume_mm3", "bbox", "faces", "closed"] }; + for k in keys { + s.insert((*k).to_string(), b.get(*k).cloned().unwrap_or(Value::Null)); + } + Value::Object(s) + }) + .collect(); + json!({ + "body_count": o.get("body_count").cloned().unwrap_or_else(|| json!(bodies.len())), + "bodies": bodies, + "total": {"volume_mm3": o.get("total").and_then(|t| t.get("volume_mm3")).cloned().unwrap_or(Value::Null)}, + }) +} + +/// A `[x, y, z]` JSON array as numbers. +fn xyz(v: &Value) -> Option<[f64; 3]> { + let a = v.as_array()?; + if a.len() != 3 { + return None; + } + let p = [a[0].as_f64()?, a[1].as_f64()?, a[2].as_f64()?]; + p.iter().all(|c| c.is_finite()).then_some(p) +} + +fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] { + [a[0] - b[0], a[1] - b[1], a[2] - b[2]] +} + +fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] { + [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]] +} + +/// One cylinder as `body_topology {faces: "cylinders"}` reports it: the faces that lie on it, +/// merged by equal radius and the same axis line. +struct TopoCylinder { + radius: f64, + origin: [f64; 3], + /// Unit length. + axis: [f64; 3], + /// Distances along `axis` from `origin` of the points found on the cylinder so far. + extent: Option<(f64, f64)>, + names: Vec, +} + +/// How far a point lies from the cylinder's axis line, and how far along it from the origin. +fn axial(c: &TopoCylinder, p: [f64; 3]) -> (f64, f64) { + let d = sub(p, c.origin); + let along = dot(d, c.axis); + let off = cross(d, c.axis); + (dot(off, off).sqrt(), along) +} + +/// Does `p` lie on the cylinder's surface, within `tol`? +fn on_cylinder(c: &TopoCylinder, p: [f64; 3], tol: f64) -> bool { + (axial(c, p).0 - c.radius).abs() <= tol +} + +fn widen(c: &mut TopoCylinder, p: [f64; 3]) { + let t = axial(c, p).1; + c.extent = Some(match c.extent { + Some((lo, hi)) => (lo.min(t), hi.max(t)), + None => (t, t), + }); +} + +/// Positions and axes that agree within this are one cylinder (mm). +const CYLINDER_TOL: f64 = 1e-3; + +/// `body_topology {faces: "cylinders"}`: the cylindrical faces of `model.faces`, the halves the +/// kernel splits one cylinder into merged (equal radius, the same axis line: origin on the axis, +/// direction up to sign), each with the extent along its axis that the body's own points on the +/// cylinder span (the faces' `point`s and the ends and midpoints of the edges lying on it). Built +/// from what `model.faces` and `model.edges` already reply, so it needs nothing of the kernel. +fn topology_cylinders(faces: &Value, edges: &Value) -> Value { + let mut found: Vec = Vec::new(); + for f in faces.as_array().into_iter().flatten() { + let c = &f["cylinder"]; + let (Some(radius), Some(origin), Some(axis)) = (c["radius"].as_f64(), xyz(&c["origin"]), xyz(&c["axis"])) else { continue }; + let len = dot(axis, axis).sqrt(); + if !radius.is_finite() || radius <= 0.0 || !len.is_finite() || len < 1e-9 { + continue; + } + let axis = [axis[0] / len, axis[1] / len, axis[2] / len]; + // The same cylinder: the same radius and axis direction (either way round), and this + // face's origin on that axis line. + let same = |k: &TopoCylinder| { + let off = cross(sub(origin, k.origin), k.axis); + (k.radius - radius).abs() <= CYLINDER_TOL && dot(k.axis, axis).abs() >= 1.0 - 1e-6 && dot(off, off).sqrt() <= CYLINDER_TOL + }; + let name = f.get("name").cloned().unwrap_or(Value::Null); + let at = match found.iter().position(same) { + Some(i) => i, + None => { + found.push(TopoCylinder { radius, origin, axis, extent: None, names: Vec::new() }); + found.len() - 1 + } + }; + if let Some(k) = found.get_mut(at) { + k.names.push(name); + if let Some(p) = xyz(&f["point"]) { + widen(k, p); + } + } + } + // The ends of the cylinder: the vertices of the edges that lie on it (a circle's ends and + // midpoint are on it; an edge of another face at most touches it in two points). + for e in edges.as_array().into_iter().flatten() { + let (Some(start), Some(mid), Some(end)) = (xyz(&e["start"]), xyz(&e["mid"]), xyz(&e["end"])) else { continue }; + for c in &mut found { + let tol = CYLINDER_TOL.max(c.radius * 1e-4); + if [start, mid, end].iter().all(|p| on_cylinder(c, *p, tol)) { + widen(c, start); + widen(c, end); + } + } + } + found + .into_iter() + .map(|c| { + let mut v = json!({"radius": c.radius, "origin": c.origin, "axis": c.axis, "names": c.names}); + if let (Some((lo, hi)), Some(o)) = (c.extent, v.as_object_mut()) { + o.insert("extent".into(), json!([lo, hi])); + } + v + }) + .collect() } /// A command's result without what the design already had (`execute` or `batch` with `verbose: false`): a @@ -1015,6 +1497,235 @@ fn compact(mut v: Value) -> Value { v } +/// `check_parameters`: try parameter values and put the design back (#3380). The checkpoint is +/// taken before anything changes; whatever happens after it, the design is restored, and a +/// restore that fails is an error saying the values are still in. Every agent session that +/// changed a parameter to see its effect spent three to five calls on this loop, and one saved +/// the design with the test values still in it. +fn check_parameters(b: &mut dyn Backend, a: &Map) -> Value { + let (changes, reports) = match check_parameters_args(a) { + Ok(x) => x, + Err(e) => return text_result(&Value::String(e), true), + }; + // A name the design has no parameter of is refused before the checkpoint, so nothing + // changes: `parameters.change` would make one. + if let Err(e) = known_parameters(b, &changes) { + return text_result(&Value::String(e), true); + } + let Some(id) = exec(b, "edit.checkpoint", json!({})).ok().and_then(|v| v["checkpoint"].as_u64()) else { + let e = "check_parameters: this app has no edit.checkpoint to put the design back with; use set_parameter and measure, and set the values back yourself"; + return text_result(&json!(e), true); + }; + let outcome = try_parameters(b, &changes, &reports); + let restore = exec(b, "edit.restore_checkpoint", json!({"checkpoint": id})); + match (outcome, restore) { + (Ok(mut reply), Ok(_)) => { + reply["restored"] = json!(true); + text_result(&reply, false) + } + (Err(e), Ok(_)) => text_result(&json!({"error": e, "restored": true, "note": "the design is back as it was"}), true), + (outcome, Err(restore_error)) => { + let mut reply = match outcome { + Ok(r) => r, + Err(e) => json!({"error": e}), + }; + reply["restored"] = json!(false); + reply["restore_error"] = json!(restore_error); + reply["note"] = json!( + "THE DESIGN STILL HOLDS THE TRIED VALUES: the checkpoint could not be restored. Set them back with set_parameter (or undo) before saving" + ); + text_result(&reply, true) + } + } +} + +/// `check_parameters`'s arguments, checked before anything is sent: the values to try (a +/// number or an expression each, as `set_parameter` takes its `value`) and what to report. +fn check_parameters_args(a: &Map) -> Result<(Map, Vec<&'static str>), String> { + let set = a.get("set").and_then(Value::as_object).ok_or("check_parameters: `set` must be an object of parameter name: value")?; + if set.is_empty() { + return Err("check_parameters: `set` is empty; give at least one parameter name: value".into()); + } + let mut changes = Map::new(); + for (name, v) in set { + let name = name.trim(); + if name.is_empty() { + return Err("check_parameters: a parameter name in `set` is empty".into()); + } + if !matches!(v, Value::Number(_) | Value::String(_)) { + let shown: String = name.chars().take(64).collect(); + return Err(format!("check_parameters: `set.{shown}` must be a number (mm) or an expression, as set_parameter's `value`")); + } + changes.insert(name.to_string(), v.clone()); + } + let reports = match a.get("report").filter(|r| !r.is_null()) { + None => REPORTS.to_vec(), + Some(r) => { + let items = r.as_array().ok_or("check_parameters: `report` must be an array of measure, cylinders, errors")?; + let mut out: Vec<&'static str> = Vec::new(); + for item in items { + let Some(known) = item.as_str().and_then(|s| REPORTS.iter().copied().find(|r| *r == s)) else { + return Err(format!("check_parameters: `report` items must be one of {}", REPORTS.join(", "))); + }; + if !out.contains(&known) { + out.push(known); + } + } + out + } + }; + Ok((changes, reports)) +} + +/// Refuse a name the design has no parameter of, before anything changes. An app without +/// `parameters.list` is not asked; the change itself then tells. +fn known_parameters(b: &mut dyn Backend, changes: &Map) -> Result<(), String> { + let Ok(list) = exec(b, "parameters.list", json!({})) else { return Ok(()) }; + let Some(rows) = list["parameters"].as_array() else { return Ok(()) }; + let names: Vec<&str> = rows.iter().filter_map(|r| r["name"].as_str()).collect(); + let shown = |n: &str| format!("`{}`", n.chars().take(64).collect::()); + let unknown: Vec = changes.keys().filter(|n| !names.contains(&n.as_str())).map(|n| shown(n)).collect(); + if unknown.is_empty() { + return Ok(()); + } + let user: Vec = rows.iter().filter(|r| r["source"] == "user").filter_map(|r| r["name"].as_str()).take(40).map(shown).collect(); + let have = if user.is_empty() { "no user parameters".to_string() } else { format!("user parameters {}", user.join(", ")) }; + Err(format!("check_parameters: no parameter {}; the design has {have} (parameters.list shows every parameter)", unknown.join(", "))) +} + +/// Apply the values and read back what was asked for: what each parameter is now, the +/// features rebuilt, and the reports. +fn try_parameters(b: &mut dyn Backend, changes: &Map, reports: &[&str]) -> Result { + let (values, recomputed) = apply_parameters(b, changes)?; + let mut reply = json!({"set": changes, "values": values, "recomputed": recomputed}); + // The bodies and each feature's state: one cheap read serves `errors` and `cylinders`. + let design = if reports.iter().any(|r| *r != "measure") { Some(b.call("document.inspect", json!({"measure": false}))?) } else { None }; + for r in reports { + match *r { + "measure" => reply["measure"] = exec(b, "inspect.measure", json!({}))?, + "errors" => { + if let Some(d) = &design { + let pick = |key: &str| -> Value { + let rows = d["timeline"].as_array().into_iter().flatten().filter(|f| !f[key].is_null()); + Value::Array(rows.map(|f| json!({"feature": f["id"], "name": f["name"], key: f[key]})).collect()) + }; + reply["errors"] = pick("error"); + reply["warnings"] = pick("warning"); + let params = d["params"].as_array().into_iter().flatten().filter(|p| !p["error"].is_null()); + reply["parameter_errors"] = Value::Array(params.map(|p| json!({"name": p["name"], "error": p["error"]})).collect()); + } + } + "cylinders" => { + if let Some(d) = &design { + let bodies: Vec = + d["bodies"].as_array().into_iter().flatten().filter_map(|body| body["name"].as_str()).map(str::to_string).collect(); + reply["cylinders"] = Value::Array(cylinders(b, &bodies)); + } + } + _ => {} + } + } + Ok(reply) +} + +/// Set the values with one `parameters.change {changes}`, so the design rebuilds once. An app +/// without `changes` (it asks for `name`, or refuses the key) gets one `parameters.change` per +/// name instead; any other refusal (an expression that does not evaluate) is the error. Returns +/// each parameter's value now and the features rebuilt. +fn apply_parameters(b: &mut dyn Backend, changes: &Map) -> Result<(Map, Value), String> { + let combined = solidcraft_engine::batch_params::combined_params(changes); + match exec(b, "parameters.change", combined) { + Ok(v) => Ok((v["changed"].as_object().cloned().unwrap_or_default(), v["recomputed"].clone())), + Err(e) if e.contains("`name`") || e.contains("`changes`") => { + let mut values = Map::new(); + let mut recomputed = 0u64; + for (name, expression) in changes { + let v = exec(b, "parameters.change", json!({"name": name, "expression": expression}))?; + values.insert(name.clone(), v["value"].clone()); + recomputed += v["recomputed"].as_u64().unwrap_or(0); + } + Ok((values, json!(recomputed))) + } + Err(e) => Err(e), + } +} + +/// Every cylindrical face of these bodies (`model.faces`), the halves one cylinder is split +/// into merged. +fn cylinders(b: &mut dyn Backend, bodies: &[String]) -> Vec { + let mut out = Vec::new(); + for body in bodies { + match exec(b, "model.faces", json!({"body": body})) { + Ok(f) => out.extend(merge_cylinders(body, f["faces"].as_array().map(Vec::as_slice).unwrap_or(&[]))), + Err(e) => out.push(json!({"body": body, "error": e})), + } + } + out +} + +/// One cylinder of a body: the faces on it (one, or the halves a kernel splits it into). +struct Cylinder { + radius: f64, + origin: [f64; 3], + axis: [f64; 3], + faces: Vec, + name: Value, + area: f64, +} + +fn vec3(v: &Value) -> Option<[f64; 3]> { + let a = v.as_array()?; + match (a.first()?.as_f64(), a.get(1)?.as_f64(), a.get(2)?.as_f64()) { + (Some(x), Some(y), Some(z)) if x.is_finite() && y.is_finite() && z.is_finite() => Some([x, y, z]), + _ => None, + } +} + +fn dot(a: [f64; 3], b: [f64; 3]) -> f64 { + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +} + +/// The cylindrical faces of one body as cylinders: faces with the same radius on the same +/// axis line are one cylinder, whatever point on the axis and whichever direction each half +/// reports. The origin is the axis point closest to the world origin and the axis points so +/// that its first non-zero component is positive, so the same cylinder reads the same whichever +/// kernel built it. +fn merge_cylinders(body: &str, faces: &[Value]) -> Vec { + let mut found: Vec = Vec::new(); + for f in faces { + let c = &f["cylinder"]; + let (Some(radius), Some(origin), Some(axis)) = (c["radius"].as_f64(), vec3(&c["origin"]), vec3(&c["axis"])) else { continue }; + let len = dot(axis, axis).sqrt(); + if !(radius > 0.0) || !radius.is_finite() || !(len > 0.0) { + continue; + } + let mut axis = axis.map(|x| x / len); + if axis.iter().find(|x| x.abs() > 1e-9).is_some_and(|x| *x < 0.0) { + // A zero component stays 0.0, not -0.0. + axis = axis.map(|x| if x.abs() > 1e-9 { -x } else { 0.0 }); + } + let t = dot(origin, axis); + let origin = [origin[0] - t * axis[0], origin[1] - t * axis[1], origin[2] - t * axis[2]]; + let tol = 1e-4 * radius.max(1.0); + let area = f["area"].as_f64().unwrap_or(0.0); + let same = |k: &Cylinder| { + let d = [k.origin[0] - origin[0], k.origin[1] - origin[1], k.origin[2] - origin[2]]; + (k.radius - radius).abs() <= tol && dot(k.axis, axis) >= 1.0 - 1e-8 && dot(d, d).sqrt() <= tol + }; + match found.iter_mut().find(|k| same(k)) { + Some(k) => { + k.faces.push(f["index"].clone()); + k.area += area; + } + None => found.push(Cylinder { radius, origin, axis, faces: vec![f["index"].clone()], name: f["name"].clone(), area }), + } + } + found + .into_iter() + .map(|k| json!({"body": body, "radius": k.radius, "origin": k.origin, "axis": k.axis, "faces": k.faces, "name": k.name, "area": k.area})) + .collect() +} + static SHOT: AtomicU64 = AtomicU64::new(0); /// `screenshot`: render or capture to a file, return it as an MCP image content block. diff --git a/crates/sketch/src/equation.rs b/crates/sketch/src/equation.rs index 90949072a3..ec78b9b05b 100644 --- a/crates/sketch/src/equation.rs +++ b/crates/sketch/src/equation.rs @@ -12,10 +12,37 @@ use crate::model::{CurveKind, Result, Sketch, SketchError}; /// Longest formula or range expression an equation curve takes (characters). pub const MAX_EQUATION_TEXT: usize = 1024; +/// What the variable of an equation curve is in its formulas (#9333). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum VariableUnit { + /// A plain number: `sin(t)` reads it in radians, and a range in a unit gives up that unit. + #[default] + Number, + /// A length, as the range is: `a ^ 2 / (4 * t)` is a length when `a` is one. + Range, +} + +impl VariableUnit { + /// The word a command or a design spells it with. + pub fn parse(word: &str) -> Option { + match word { + "number" => Some(VariableUnit::Number), + "range" => Some(VariableUnit::Range), + _ => None, + } + } + + fn is_number(&self) -> bool { + *self == VariableUnit::Number + } +} + /// A curve drawn from formulas: x(t) and y(t) over t from `start` to `end`, or (with `x` left /// out) the explicit y = f(x) over x from `start` to `end`. The formulas use the design's -/// expression language and parameters; the variable is a plain number, so `sin(t)` reads it in -/// radians, and a result without a unit is in the design's length unit. +/// expression language and parameters; the variable is a plain number unless `variable_unit` +/// says otherwise, so `sin(t)` reads it in radians, and a result without a unit is in the +/// design's length unit. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct EquationCurve { /// The fit point spline it places (a curve id of the sketch). @@ -31,6 +58,9 @@ pub struct EquationCurve { /// How far (mm) the spline may stray from the formulas: the number of points was chosen for /// it when the curve was made or last edited. pub tolerance: f64, + /// What the variable is in the formulas. Left out of the file for a plain number. + #[serde(default, skip_serializing_if = "VariableUnit::is_number")] + pub variable_unit: VariableUnit, } impl EquationCurve { diff --git a/crates/sketch/src/lib.rs b/crates/sketch/src/lib.rs index 6621a6263f..068f85c860 100644 --- a/crates/sketch/src/lib.rs +++ b/crates/sketch/src/lib.rs @@ -52,7 +52,7 @@ pub use dims::{ DimFrame, DimLayout, chain_centres, default_text, default_text_offset, dim_frame, dim_layout, dim_layout_reaching, dim_reach, encode_text, }; pub use ends::{END_TOL, EndReport, EndTouch, UntiedEnd, loose_ends}; -pub use equation::{EquationCurve, MAX_EQUATION_TEXT}; +pub use equation::{EquationCurve, MAX_EQUATION_TEXT, VariableUnit}; pub use fonts::{DEFAULT_FONT_FAMILY, FontData, families_in, find_style_in, font_data, font_data_styled, installed_families, set_font_file_check}; pub use freeform_offset::{FreeformOffset, FreeformOffsetOutput, OFFSET_TOLERANCE}; pub use link::{Link, LinkGeom, LinkKind, LinkSource, MAX_LINK_ENTITIES, SKETCH_AXIS_HALF, TextExprs, sketch_axis_geom}; diff --git a/crates/sketch/src/offset.rs b/crates/sketch/src/offset.rs index 23b013dab4..cace58562c 100644 --- a/crates/sketch/src/offset.rs +++ b/crates/sketch/src/offset.rs @@ -67,6 +67,13 @@ impl Sketch { output(&arc.id) && self.curves.iter().enumerate().any(|(k, other)| k != curve && other.kind.uses(centre) && !output(&other.id)) } + /// A circle an offset made (#9264). The offset sets its radius from the source's after the + /// solve, so the solve holds that radius instead of counting it as a free unknown. + pub(crate) fn offset_circle_follows_source(&self, curve: usize) -> bool { + let Some(circle) = self.curves.get(curve) else { return false }; + matches!(circle.kind, CurveKind::Circle { .. }) && self.offsets.iter().any(|o| o.owns_output(&circle.id)) + } + /// Endpoints of a normal-distance annotation, evaluated from its source curve. pub fn offset_dimension_points(&self, output: &str) -> Option<(Vec2, Vec2)> { let (source, forward, distance) = self @@ -115,6 +122,80 @@ impl Sketch { } } + /// Retry a failed solve whose source edit was blocked by attachments to an offset's old + /// fixed points. Seed the sources without those attachments, rebuild the offsets, then + /// solve every attachment with the sources held. Nothing is adopted unless the complete + /// sketch solves with its original constraints and orientation. + pub fn solve_after_offset_edit( + &mut self, + old: &crate::DrivingValues, + reference: Option<&[Vec2]>, + analysis: crate::Analysis, + ) -> Option { + let source = + |id: &str| self.offsets.iter().any(|o| o.sources.iter().any(|(s, _)| s == id)) || self.freeform_offsets.iter().any(|o| o.source == id); + let output = |id: &str| self.offsets.iter().any(|o| o.owns_output(id)) || self.freeform_offsets.iter().any(|o| o.output == id); + let source_points: std::collections::BTreeSet = + self.curves.iter().filter(|c| source(&c.id)).flat_map(|c| c.kind.point_ids()).collect(); + let outputs: std::collections::BTreeSet = self.curves.iter().enumerate().filter(|(_, c)| output(&c.id)).map(|(i, _)| i).collect(); + let output_points: std::collections::BTreeSet = + outputs.iter().filter_map(|i| self.curves.get(*i)).flat_map(|c| c.kind.point_ids()).filter(|p| !source_points.contains(p)).collect(); + let mut candidate = self.clone(); + candidate.constraints.retain(|c| { + !c.kind.indices().1.iter().any(|i| outputs.contains(i)) && !self.constraint_points(&c.kind).iter().any(|p| output_points.contains(p)) + }); + if candidate.constraints.len() == self.constraints.len() { + return None; + } + let before: Vec = reference.map_or_else(|| self.points.iter().map(|p| p.pos).collect(), <[Vec2]>::to_vec); + if !crate::solve_after_edit_with_analysis(&mut candidate, old, Some(&before), crate::Analysis::StatusOnly).keeps_orientation() { + return None; + } + candidate.constraints.clone_from(&self.constraints); + candidate.refresh_offsets().ok()?; + // Keep the freshly rebuilt offset exact while its attached geometry catches up. + // Refresh may retire output curves or refine a dependent spline: resolve holds again. + let hold = crate::Hold { + points: candidate.curves.iter().filter(|c| source(&c.id)).flat_map(|c| c.kind.point_ids()).collect(), + radii: candidate.curves.iter().enumerate().filter(|(_, c)| source(&c.id) || output(&c.id)).map(|(i, _)| i).collect(), + }; + if !crate::solve_with(&mut candidate, &hold, Some(&before), crate::Analysis::StatusOnly, None).keeps_orientation() { + // A cap tangent to an arc can land on the other tangency branch after a large + // jump. Walk the held source and output points from their stored positions. + // Their own dimensions are restored after the walk, not imposed at each step. + let mut walking = candidate.clone(); + walking.constraints.retain(|c| candidate.constraint_points(&c.kind).iter().any(|p| !hold.points.contains(p))); + for i in &hold.points { + if let Some(p) = walking.points.get_mut(*i) { + p.fixed = true; + } + } + for i in &hold.radii { + if let Some(c) = walking.curves.get_mut(*i) { + c.fixed = true; + } + } + let mut from = walking.clone(); + for (p, pos) in from.points.iter_mut().zip(&before) { + p.pos = *pos; + } + crate::solve_after_link_move(&mut walking, &from, old, crate::Analysis::StatusOnly)?; + for (p, q) in candidate.points.iter_mut().zip(&walking.points) { + p.pos = q.pos; + } + for (c, d) in candidate.curves.iter_mut().zip(&walking.curves) { + c.kind.clone_from(&d.kind); + } + } + // Publish ordinary freedom/redundancy diagnostics, without the temporary holds. + let report = crate::solve_after_edit_with_analysis(&mut candidate, old, Some(&before), analysis); + if !report.keeps_orientation() { + return None; + } + *self = candidate; + Some(report) + } + pub fn refresh_offsets(&mut self) -> Result<(), SketchError> { if self.offsets.is_empty() && self.freeform_offsets.is_empty() { return Ok(()); diff --git a/crates/sketch/src/profiling.rs b/crates/sketch/src/profiling.rs index 667fddba1b..38d002b0ea 100644 --- a/crates/sketch/src/profiling.rs +++ b/crates/sketch/src/profiling.rs @@ -63,6 +63,8 @@ pub enum StopReason { ResidualTolerance, NoImprovement, IterationLimit, + /// A status-only solve gave up: the iteration stalled far from a solution. + Stalled, /// The shared frame-work budget was exhausted after evaluating the initial residual. WorkBudget, } diff --git a/crates/sketch/src/shape.rs b/crates/sketch/src/shape.rs index 92847d687a..0b19dbf7c2 100644 --- a/crates/sketch/src/shape.rs +++ b/crates/sketch/src/shape.rs @@ -141,14 +141,24 @@ const ON_CURVE: f64 = 1e-4; /// Curves considered for a snap, so a hostile sketch cannot stall the pointer. const MAX_INTERSECTION_CANDIDATES: usize = 256; +/// Infinite construction lines use their full support; all other curves keep their bounds. +fn snap_distance(shape: &Shape, infinite: bool, p: Vec2) -> f64 { + if infinite && matches!(shape, Shape::Line { .. }) { p.dist(shape.project(p)) } else { shape.dist(p) } +} + /// The crossing of `x` and `y` nearest `near` that lies on both bounded curves. pub fn nearest_crossing(x: &Shape, y: &Shape, near: Vec2) -> Option { + nearest_crossing_with_extents(x, y, near, [false; 2]) +} + +fn nearest_crossing_with_extents(x: &Shape, y: &Shape, near: Vec2, infinite: [bool; 2]) -> Option { if !near.is_finite() { return None; } let mut best: Option<(f64, Vec2)> = None; + let [infinite_x, infinite_y] = infinite; for q in intersections_within(x, y, 1e-6) { - if !q.is_finite() || x.dist(q) > ON_CURVE || y.dist(q) > ON_CURVE { + if !(q.is_finite() && snap_distance(x, infinite_x, q) <= ON_CURVE && snap_distance(y, infinite_y, q) <= ON_CURVE) { continue; } let d = q.dist(near); @@ -161,28 +171,29 @@ pub fn nearest_crossing(x: &Shape, y: &Shape, near: Vec2) -> Option { /// The nearest crossing of two lines, circles or arcs of `sk` to `p` within `tol`. /// -/// Skips a crossing that already has a sketch point. Curves whose bounded distance from `p` -/// is greater than `tol` cannot meet near the cursor and are ignored. +/// Skips a crossing that already has a sketch point. Curves farther than `tol` from `p` +/// cannot meet near the cursor and are ignored. Infinite lines use their full support; +/// ordinary segments and arcs keep their bounds. pub fn nearest_curve_intersection(sk: &Sketch, p: Vec2, tol: f64) -> Option<(Vec2, usize, usize)> { if !p.is_finite() || !tol.is_finite() || tol <= 0.0 { return None; } - let mut candidates: Vec<(usize, Shape)> = Vec::new(); - for i in 0..sk.curves.len() { + let mut candidates: Vec<(usize, Shape, bool)> = Vec::new(); + for (i, curve) in sk.curves.iter().enumerate() { if candidates.len() >= MAX_INTERSECTION_CANDIDATES { break; } let Some(sh) = sk.shape(i) else { continue }; - if sh.dist(p) <= tol { - candidates.push((i, sh)); + if snap_distance(&sh, curve.infinite, p) <= tol { + candidates.push((i, sh, curve.infinite)); } } let mut best: Option<(f64, Vec2, usize, usize)> = None; for a in 0..candidates.len() { for b in a + 1..candidates.len() { - let Some((ia, sa)) = candidates.get(a) else { continue }; - let Some((ib, sb)) = candidates.get(b) else { continue }; - let Some(q) = nearest_crossing(sa, sb, p) else { continue }; + let Some((ia, sa, infinite_a)) = candidates.get(a) else { continue }; + let Some((ib, sb, infinite_b)) = candidates.get(b) else { continue }; + let Some(q) = nearest_crossing_with_extents(sa, sb, p, [*infinite_a, *infinite_b]) else { continue }; let d = q.dist(p); if d > tol { continue; @@ -253,6 +264,12 @@ impl Shape { } impl Sketch { + /// The nearest crossing of two analytic curves, respecting infinite construction lines. + pub fn nearest_crossing(&self, a: usize, b: usize, near: Vec2) -> Option { + let infinite = [self.curves.get(a)?.infinite, self.curves.get(b)?.infinite]; + nearest_crossing_with_extents(&self.shape(a)?, &self.shape(b)?, near, infinite) + } + /// The analytic shape of a curve (arcs counter-clockwise from their stored start). pub fn shape(&self, ci: usize) -> Option { match self.curves.get(ci)?.kind { @@ -286,6 +303,51 @@ mod intersection_snap_tests { assert_eq!([i.min(j), i.max(j)], [a.min(b), a.max(b)]); } + #[test] + fn infinite_line_crossing_snaps_beyond_both_defining_segments() { + for reverse in [false, true] { + let mut sk = Sketch::new(); + let mut lines = [(v(20.0, 40.0), v(20.0, 50.0)), (v(40.0, 15.0), v(50.0, 15.0))]; + if reverse { + lines.reverse(); + } + for (a, b) in lines { + let i = sk.add_line(a, b, None, None, None).unwrap(); + sk.curves[i].construction = true; + } + let near = v(20.4, 15.4); + assert!(nearest_curve_intersection(&sk, near, 1.0).is_none(), "finite construction segments do not cross"); + for curve in &mut sk.curves { + curve.infinite = true; + } + let (at, a, b) = nearest_curve_intersection(&sk, near, 1.0).expect("infinite lines cross"); + assert!(at.dist(v(20.0, 15.0)) < 1e-9, "{at:?}"); + assert_ne!(a, b); + } + } + + #[test] + fn infinite_line_crossing_keeps_the_other_curves_bounds() { + let mut sk = Sketch::new(); + let line = sk.add_line(v(-6.0, 30.0), v(-6.0, 40.0), None, None, None).unwrap(); + sk.curves[line].infinite = true; + sk.curves[line].construction = true; + sk.add_arc(v(0.0, 0.0), v(10.0, 0.0), v(0.0, 10.0), [None; 3], None).unwrap(); + assert!(nearest_curve_intersection(&sk, v(-6.0, 8.0), 1.0).is_none(), "the missing part of the arc is not a crossing"); + let CurveKind::Line { a, b } = sk.curves[line].kind else { panic!("line") }; + sk.points[a].pos.x = 6.0; + sk.points[b].pos.x = 6.0; + let (at, _, _) = nearest_curve_intersection(&sk, v(6.2, 8.2), 1.0).expect("the drawn arc crosses the infinite line"); + assert!(at.dist(v(6.0, 8.0)) < 1e-9, "{at:?}"); + + let mut sk = Sketch::new(); + let vertical = sk.add_line(v(20.0, 40.0), v(20.0, 50.0), None, None, None).unwrap(); + sk.curves[vertical].infinite = true; + sk.curves[vertical].construction = true; + sk.add_line(v(40.0, 15.0), v(50.0, 15.0), None, None, None).unwrap(); + assert!(nearest_curve_intersection(&sk, v(20.4, 15.4), 1.0).is_none(), "the finite line must not extend"); + } + #[test] fn nearest_curve_intersection_skips_a_crossing_that_already_has_a_point() { let mut sk = Sketch::new(); diff --git a/crates/sketch/src/solver.rs b/crates/sketch/src/solver.rs index 3e3ab93540..8f74c2887e 100644 --- a/crates/sketch/src/solver.rs +++ b/crates/sketch/src/solver.rs @@ -42,6 +42,19 @@ pub const MAX_COMPONENT_VARS: usize = 4_000; const TOL: f64 = 1e-9; /// A solution is accepted when every residual is below this (mm). const ACCEPT: f64 = 1e-6; +/// A solve that only asks whether it succeeds ([`Analysis::StatusOnly`], the held solves of a +/// drag) gives up on a component once LM has stalled far from a solution: over the last +/// `STALL_ITERS` iterations the sum of squared residuals fell by at most `STALL_DECREASE` of +/// itself, and the largest residual is still above `STALL_RESIDUAL` (mm). Such a run sits in a +/// least-squares valley of constraints that conflict, crawling until `MAX_ITERS`, and a held +/// solve runs LM up to three times (#6606). Measured on about 62,000 LM runs that succeeded +/// (the sketch and engine tests and 120 generated sketch scripts with 2,900 drags), the slowest +/// still fell by 1.4e-3 of itself over any 20 iterations while it was that far off: some cross +/// a plateau for dozens of iterations and then converge, so the threshold stays a hundred times +/// below that. Other solves keep iterating: their report names what a failure leaves violated. +const STALL_ITERS: usize = 20; +const STALL_DECREASE: f64 = 1e-5; +const STALL_RESIDUAL: f64 = 1e-4; /// How much less it costs a solve to move a point held on a curve than any other point: such a /// point is placed relative to the curve, so it follows the curve rather than the curve it. const SLIDE_WEIGHT: f64 = 0.01; @@ -183,7 +196,9 @@ fn layout(sk: &Sketch, hold: &Hold) -> Layout { .iter() .enumerate() .map(|(i, c)| match c.kind { - CurveKind::Circle { .. } | CurveKind::Ellipse { .. } if c.link.is_none() && !c.fixed && !hold.radii.contains(&i) => { + CurveKind::Circle { .. } | CurveKind::Ellipse { .. } + if c.link.is_none() && !c.fixed && !hold.radii.contains(&i) && !sk.offset_circle_follows_source(i) => + { n += 1; Some(n - 1) } @@ -1110,13 +1125,11 @@ fn norm2(v: &[f64]) -> f64 { } /// Levenberg–Marquardt on one component (variables `vars`, blocks `blocks`). Returns the -/// iterations and the largest residual left. -fn lm(sk: &Sketch, lay: &Layout, blocks: &[(Block, Vec)], vars: &[usize], x: &mut Vec, col: &[usize]) -> (usize, f64) { - lm_capped(sk, lay, blocks, vars, x, col, MAX_ITERS) -} - -/// [`lm`], counting its work against a live drag frame's numerical allowance when `budget` has -/// one ([`Budget::frame`]). The solve itself runs as it would without it. +/// iterations and the largest residual left. Its work counts against a live drag frame's +/// numerical allowance when `budget` has one ([`Budget::frame`]); the solve itself runs as it +/// would without it. `give_up`: stop once +/// stalled (see [`STALL_ITERS`]); not in a live frame, whose allowance is measured in the work +/// the full iteration does. fn lm_in( sk: &Sketch, lay: &Layout, @@ -1125,9 +1138,10 @@ fn lm_in( x: &mut Vec, col: &[usize], budget: &mut Budget, + give_up: bool, ) -> (usize, f64) { if !budget.counts_numeric() { - return lm(sk, lay, blocks, vars, x, col); + return lm_run(sk, lay, blocks, vars, x, col, MAX_ITERS, None, give_up).unwrap_or((0, f64::INFINITY)); } let mut meter = Budget::new(usize::MAX); let out = lm_capped_with_budget(sk, lay, blocks, vars, x, col, MAX_ITERS, Some(&mut meter)).unwrap_or((0, f64::INFINITY)); @@ -1152,6 +1166,20 @@ fn lm_capped( /// Deterministic work units, shared with the frame's decomposition budget. Estimates bound /// dense arithmetic; they are not a wall-clock deadline. None means no residual was evaluated. fn lm_capped_with_budget( + sk: &Sketch, + lay: &Layout, + blocks: &[(Block, Vec)], + vars: &[usize], + x: &mut Vec, + col: &[usize], + max_iters: usize, + budget: Option<&mut Budget>, +) -> Option<(usize, f64)> { + lm_run(sk, lay, blocks, vars, x, col, max_iters, budget, false) +} + +/// [`lm_capped_with_budget`], giving up once stalled when `give_up` (see [`STALL_ITERS`]). +fn lm_run( sk: &Sketch, lay: &Layout, blocks: &[(Block, Vec)], @@ -1160,6 +1188,7 @@ fn lm_capped_with_budget( col: &[usize], max_iters: usize, mut budget: Option<&mut Budget>, + give_up: bool, ) -> Option<(usize, f64)> { #[cfg(feature = "solver-profiling")] let _timer = profiling::timer(Phase::Lm); @@ -1175,12 +1204,25 @@ fn lm_capped_with_budget( let mut f = norm2(&r); #[cfg(feature = "solver-profiling")] let run = profiling::start_run(n, r.len(), max_iters, f); + // The sum of squares at the start of each iteration, while giving up is allowed. + let mut costs = Vec::new(); for it in 0..max_iters { if max_abs(&r) < TOL { #[cfg(feature = "solver-profiling")] profiling::finish_run(run, StopReason::ResidualTolerance, max_abs(&r)); return Some((it, max_abs(&r))); } + if give_up { + if let Some(&earlier) = it.checked_sub(STALL_ITERS).and_then(|k| costs.get(k)) + && max_abs(&r) > STALL_RESIDUAL + && earlier - f <= STALL_DECREASE * earlier + { + #[cfg(feature = "solver-profiling")] + profiling::finish_run(run, StopReason::Stalled, max_abs(&r)); + return Some((it, max_abs(&r))); + } + costs.push(f); + } let normal_work = n.saturating_mul(n).saturating_mul(r.len()).saturating_add(evaluation_work.saturating_mul(n.saturating_mul(2).saturating_add(1))); if !within_budget(&mut budget, normal_work) { @@ -2726,6 +2768,10 @@ fn solve_inner( } // Column of each variable in the system being solved or analysed (usize::MAX outside it). let mut col = vec![usize::MAX; lay.n]; + // Only success matters to a status-only solve: a component LM cannot solve is left as soon + // as the iteration stalls (see `STALL_ITERS`). It fails either way, and a failed solve + // leaves the geometry as it was. + let give_up = analysis == Analysis::StatusOnly; let mut iterations = 0; let mut continuations = 0; @@ -2761,7 +2807,7 @@ fn solve_inner( let rest = unpinned(vars, &pin, None); let rest_blocks = blocks_reading(&sub, &rest); columns(&mut col, &rest, vars); - let (its, r) = if rest.is_empty() { (0, 0.0) } else { lm_in(sk, &lay, &rest_blocks, &rest, &mut x, &col, budget) }; + let (its, r) = if rest.is_empty() { (0, 0.0) } else { lm_in(sk, &lay, &rest_blocks, &rest, &mut x, &col, budget, give_up) }; iterations += its; residual = r; solved = residual < ACCEPT; @@ -2785,7 +2831,7 @@ fn solve_inner( // the seed did not move it. let reseeded = unseeded.as_ref().filter(|plain| vars.iter().any(|v| plain.get(*v).is_some_and(Option::is_some))); if !(solved || pinned_failed && reseeded.is_some()) { - let (its, r) = lm_in(sk, &lay, &sub, vars, &mut x, &col, budget); + let (its, r) = lm_in(sk, &lay, &sub, vars, &mut x, &col, budget, give_up); iterations += its; residual = r; } @@ -2799,7 +2845,7 @@ fn solve_inner( *s = plain.get(v).copied().flatten().unwrap_or(*s0); } } - let (its, r) = lm_in(sk, &lay, &sub, vars, &mut x, &col, budget); + let (its, r) = lm_in(sk, &lay, &sub, vars, &mut x, &col, budget, give_up); iterations += its; residual = r; } @@ -2830,7 +2876,7 @@ fn solve_inner( iterations += its; if !done { // The last sub-step's solution, solved for the new values in one jump. - let (its, r) = lm_in(sk, &lay, &sub, vars, &mut from, &col, budget); + let (its, r) = lm_in(sk, &lay, &sub, vars, &mut from, &col, budget, false); iterations += its; done = edit.holds(r, &State { sk, lay: &lay, x: &from }); } diff --git a/crates/sketch/src/tests.rs b/crates/sketch/src/tests.rs index d9347a9396..2dce5a775c 100644 --- a/crates/sketch/src/tests.rs +++ b/crates/sketch/src/tests.rs @@ -1438,3 +1438,36 @@ fn rounded_rectangles_sharing_their_sides_leave_every_profile_closed() { } } } + +/// A drag step that cannot succeed gives up once the iteration has stalled (#6606). Holding +/// the two right corners of a rectangle further apart than its dimensioned left side leaves the +/// left corners a least-squares valley (the perpendicular repeats what horizontal and vertical +/// already say), along which the iteration crawled for its whole allowance. +#[test] +fn a_held_solve_that_cannot_succeed_stops_once_it_stalls() { + let mut sk = Sketch::new(); + let [bottom, right, top, left] = rect(&mut sk, [v(-6.0, -20.0), v(23.5, -20.8), v(23.5, 0.0), v(-6.0, 0.0)]); + for kind in [ + ConstraintKind::Horizontal { l: bottom }, + ConstraintKind::Horizontal { l: top }, + ConstraintKind::Vertical { l: right }, + ConstraintKind::Vertical { l: left }, + ConstraintKind::Length { l: left, value: 20.0 }, + ConstraintKind::Perpendicular { a: top, b: left }, + ] { + sk.add_constraint(kind, None).unwrap(); + } + let hold = Hold { points: sk.curves[right].kind.point_ids(), radii: vec![] }; + let reference: Vec = sk.points.iter().map(|p| p.pos).collect(); + let solve = |analysis| { + let mut moved = sk.clone(); + let rep = solve_with(&mut moved, &hold, Some(&reference), analysis, None); + assert!(!rep.ok()); + assert_eq!(moved.points, sk.points, "a failed solve leaves the geometry as it was"); + rep.iterations + }; + let stalled = solve(Analysis::StatusOnly); + assert!(stalled < 100, "a held solve gives up once stalled: {stalled} iterations"); + // A solve whose report names the violated constraints still iterates to the end. + assert!(solve(Analysis::Full) >= 500); +} diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 6afabfdf75..8fb86ab2e5 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -25,6 +25,9 @@ ttf-parser = { workspace = true } # Right-to-left interface text (Hebrew): the Unicode Bidirectional Algorithm puts translated # strings in display order, which egui does not do itself (MIT OR Apache-2.0). unicode-bidi = "0.3" +# The system's preferred languages on a first start (MIT OR Apache-2.0, maintained by +# 1Password): the OS locale on desktop and, with `js`, `navigator.languages` in the browser (#10138). +sys-locale = { version = "0.3.2", features = ["js"] } semver = { workspace = true } # Updates: the downloaded package's SHA-256 (against the release's SHA256SUMS.txt), its optional # minisign signature (MIT), and the portable .zip's executables. diff --git a/crates/ui-egui/src/agent_chat/acp.rs b/crates/ui-egui/src/agent_chat/acp.rs index 897f91d40a..2f5e4d49fa 100644 --- a/crates/ui-egui/src/agent_chat/acp.rs +++ b/crates/ui-egui/src/agent_chat/acp.rs @@ -211,11 +211,14 @@ pub fn wsl_path(path: &std::path::Path) -> std::path::PathBuf { } } -/// What every session is told about where it is. Agents that don't read `_meta` ignore it. +/// What every session is told about where it is. An agent with no route for it in +/// [`session_new_params`] doesn't get it. pub const SYSTEM_NOTE: &str = "You are running inside SolidCraft, a parametric 3D CAD application, as the user's assistant. \ The open design is reachable through the `solidcraft-app` MCP server: use its tools (list_commands, batch, execute, inspect_design, \ -measure, screenshot, set_parameter, undo) to read and change the design the user is looking at, and verify with measure or \ -inspect_design. Prefer one `batch` over many small calls. Saving, opening or exporting files waits for the user's approval in \ +measure, screenshot, set_parameter, undo, skill_reference) to read and change the design the user is looking at, and verify with \ +measure or inspect_design. Before building anything beyond one primitive, read `skill_reference` for `solidcraft-modeling` (its \ +SKILL.md has the workflow, its reference.md the command shapes and this version's traps), work the numbers out first, and after \ +each stage compare `measure` with them. Prefer one `batch` over many small calls. Saving, opening or exporting files waits for the user's approval in \ the chat, and each message allows a limited number of changes. Don't use shell or file tools: work through the SolidCraft tools. \ Keep answers short."; @@ -224,19 +227,44 @@ Keep answers short."; pub const CLAUDE_DISALLOWED_TOOLS: &[&str] = &["Bash", "BashOutput", "KillShell", "Read", "Write", "Edit", "MultiEdit", "NotebookEdit", "Glob", "Grep", "WebFetch", "WebSearch", "Task"]; -pub fn session_new_params(cwd: &std::path::Path, bridge: Option<&McpBridge>) -> Value { +/// The `session/new` parameters. `init` is the agent's `initialize` reply: agents read the system +/// note from different places (#9811), and the standard field may only go to an agent offering it. +pub fn session_new_params(cwd: &std::path::Path, bridge: Option<&McpBridge>, init: &Value) -> Value { let servers: Vec = bridge.map(McpBridge::to_json).into_iter().collect(); - json!({ + let mut meta = json!({ + // The Claude adapter's own key, for its versions without the standard field. + "systemPrompt": {"append": SYSTEM_NOTE}, + // Hosts may opt a session out of "bypass permissions" (the Claude adapter reads it): + // every tool call the agent asks about reaches the panel. + // Its own shell and file tools are off: the agent works through SolidCraft's tools. + "claudeCode": {"options": {"allowDangerouslySkipPermissions": false, "disallowedTools": CLAUDE_DISALLOWED_TOOLS}}, + }); + if takes_antigravity_note(init) + && let Some(meta) = meta.as_object_mut() + { + meta.insert("antigravity".into(), json!({"systemPrompt": {"append": SYSTEM_NOTE}})); + } + let mut params = json!({ "cwd": cwd.to_string_lossy(), "mcpServers": servers, - "_meta": { - "systemPrompt": {"append": SYSTEM_NOTE}, - // Hosts may opt a session out of "bypass permissions" (the Claude adapter reads it): - // every tool call the agent asks about reaches the panel. - // Its own shell and file tools are off: the agent works through SolidCraft's tools. - "claudeCode": {"options": {"allowDangerouslySkipPermissions": false, "disallowedTools": CLAUDE_DISALLOWED_TOOLS}}, - }, - }) + "_meta": meta, + }); + if takes_standard_note(init) + && let Some(params) = params.as_object_mut() + { + params.insert("systemPrompt".into(), json!({"mode": "append", "content": [{"type": "text", "text": SYSTEM_NOTE}]})); + } + params +} + +/// The agent offers ACP's `systemPrompt` field on its sessions. +fn takes_standard_note(init: &Value) -> bool { + init.pointer("/agentCapabilities/sessionCapabilities/systemPrompt").is_some_and(|offer| !offer.is_null()) +} + +/// Antigravity offers its own key under `_meta` and doesn't read the Claude adapter's. +fn takes_antigravity_note(init: &Value) -> bool { + init.pointer("/agentCapabilities/_meta/antigravity/systemPrompt/append").and_then(Value::as_bool).unwrap_or(false) } pub fn prompt_params(session: &str, parts: &[super::backend::PromptPart]) -> Value { @@ -677,14 +705,47 @@ mod tests { #[test] fn the_session_gets_the_bridged_mcp_server_without_a_token_or_any_path() { let b = McpBridge { cli: "/opt/solidcraft/solidcraft-cli".into(), port: 41234 }; - let p = session_new_params(std::path::Path::new("/home/u/designs"), Some(&b)); + let p = session_new_params(std::path::Path::new("/home/u/designs"), Some(&b), &json!({})); let s = &p["mcpServers"][0]; assert_eq!(s["name"], BRIDGE_NAME); assert_eq!(s["args"], json!(["mcp", "--connect", "127.0.0.1:41234"])); let text = p.to_string(); assert!(!text.contains("allow-any-path") && !text.contains("TOKEN") && !text.contains("token"), "{text}"); assert!(p["_meta"]["claudeCode"]["options"]["disallowedTools"].as_array().is_some_and(|t| t.iter().any(|x| x == "Bash"))); - assert_eq!(session_new_params(std::path::Path::new("/tmp"), None)["mcpServers"], json!([])); + assert_eq!(session_new_params(std::path::Path::new("/tmp"), None, &json!({}))["mcpServers"], json!([])); + } + + /// #9811: the note goes by the routes an agent's `initialize` reply offers, and the standard + /// field never goes to an agent that doesn't offer it, as the protocol asks. + #[test] + fn the_system_note_takes_every_route_the_agent_offers() { + let params = |init: Value| session_new_params(std::path::Path::new("/tmp"), None, &init); + let standard = json!({"mode": "append", "content": [{"type": "text", "text": SYSTEM_NOTE}]}); + + let offers_standard = params(json!({"agentCapabilities": {"sessionCapabilities": {"systemPrompt": {}}}})); + assert_eq!(offers_standard["systemPrompt"], standard); + assert!(offers_standard["_meta"].get("antigravity").is_none()); + + let offers_antigravity = params(json!({"agentCapabilities": {"_meta": {"antigravity": {"systemPrompt": {"append": true}}}}})); + assert_eq!(offers_antigravity["_meta"]["antigravity"]["systemPrompt"]["append"], SYSTEM_NOTE); + assert!(offers_antigravity.get("systemPrompt").is_none()); + + for none in [ + json!({}), + json!({"agentCapabilities": {"sessionCapabilities": {"systemPrompt": null}}}), + json!({"agentCapabilities": {"_meta": {"antigravity": {"systemPrompt": {"append": false}}}}}), + ] { + let p = params(none); + assert!(p.get("systemPrompt").is_none(), "{p}"); + assert!(p["_meta"].get("antigravity").is_none(), "{p}"); + } + + // Every session keeps the key older Claude adapters read, and the tools they turn off. + for init in [json!({}), json!({"agentCapabilities": {"sessionCapabilities": {"systemPrompt": {}}}})] { + let p = params(init); + assert_eq!(p["_meta"]["systemPrompt"]["append"], SYSTEM_NOTE); + assert!(p["_meta"]["claudeCode"]["options"]["disallowedTools"].is_array()); + } } /// #5546: an agent started through `wsl.exe` gets the session's paths as WSL shows them. @@ -707,7 +768,7 @@ mod tests { assert_eq!(wsl(r"\\server\share\x"), r"\\server\share\x"); let b = McpBridge { cli: r"C:\Program Files\SolidCraft\solidcraft-cli.exe".into(), port: 41234 }; - let p = session_new_params(&wsl_path(std::path::Path::new(r"C:\Users\u\work")), Some(&b.in_wsl())); + let p = session_new_params(&wsl_path(std::path::Path::new(r"C:\Users\u\work")), Some(&b.in_wsl()), &json!({})); assert_eq!(p["cwd"], "/mnt/c/Users/u/work"); assert_eq!(p["mcpServers"][0]["command"], "/mnt/c/Program Files/SolidCraft/solidcraft-cli.exe"); assert_eq!(p["mcpServers"][0]["args"], json!(["mcp", "--connect", "127.0.0.1:41234"])); diff --git a/crates/ui-egui/src/agent_chat/acp_client.rs b/crates/ui-egui/src/agent_chat/acp_client.rs index 3f0e1eb348..e9f88f4bbf 100644 --- a/crates/ui-egui/src/agent_chat/acp_client.rs +++ b/crates/ui-egui/src/agent_chat/acp_client.rs @@ -29,6 +29,8 @@ pub struct AcpBackend { proc: AgentProcess, cwd: std::path::PathBuf, bridge: Option, + /// The agent's `initialize` reply: which routes the system note can take into a session. + init: Value, next_id: u64, pending: HashMap, session: Option, @@ -56,6 +58,7 @@ impl AcpBackend { proc, cwd, bridge, + init: Value::Null, next_id: 0, pending: HashMap::new(), session: None, @@ -82,7 +85,7 @@ impl AcpBackend { fn new_session(&mut self) { self.config_pending = None; self.pending.retain(|_, request| *request != Pending::SetConfig); - let params = acp::session_new_params(&self.cwd, self.bridge.as_ref()); + let params = acp::session_new_params(&self.cwd, self.bridge.as_ref(), &self.init); self.request(Pending::SessionNew, "session/new", params); } @@ -96,6 +99,7 @@ impl AcpBackend { self.images = acp::takes_images(&v); self.documents = acp::takes_documents(&v); self.auth = acp::auth_methods(&v); + self.init = v; self.new_session(); } (Pending::SessionNew, Ok(v)) => match v.get("sessionId").and_then(Value::as_str) { @@ -139,6 +143,11 @@ impl AcpBackend { out.push(Event::ConfigOptionFinished { id: config_id, result }); } } + // `session/set_model` is still unstable in ACP: an agent can list `models` and + // still not serve it (#9970). It keeps its own model; that is no error to show. + (Pending::SetModel, Err((acp::METHOD_NOT_FOUND, message))) => { + log::debug!("agent does not serve session/set_model: {message}"); + } (what, Err((code, message))) => { let config_error = what == Pending::SetConfig; if what == Pending::SetConfig @@ -317,7 +326,7 @@ pub fn test_agent(argv: &[String], cwd: &std::path::Path) -> Result u64 { secs.round().clamp(MIN_REQUEST_TIMEOUT_S as f64, MAX_REQUEST_TIMEOUT_S as f64) as u64 } +/// The longest reasoning effort an endpoint can be given (no provider names one near this). +pub const MAX_REASONING_EFFORT_LEN: usize = 64; + +/// A reasoning effort as stored: trimmed, and capped so a hand-edited preferences file or the +/// control channel cannot carry an absurd value into every request. Empty means "send nothing". +pub fn reasoning_effort(raw: &str) -> String { + raw.trim().chars().take(MAX_REASONING_EFFORT_LEN).collect() +} + /// An agent as the picker and the switcher show it. #[derive(Clone, Debug, PartialEq)] pub struct Entry { @@ -167,6 +180,12 @@ pub struct Entry { pub key: bool, /// API: seconds one model request may take (in range). pub request_timeout_s: u64, + /// API: the reasoning effort to send, empty for none (trimmed, in length). + pub reasoning_effort: String, + /// Found installed, but its program didn't start when asked its version: why (#9034). + pub problem: Option, + /// Found installed: the command that installs it again. + pub install: &'static str, } impl Entry { @@ -183,18 +202,32 @@ impl Entry { model: c.model.clone(), key: c.key, request_timeout_s: c.request_timeout_s.clamp(MIN_REQUEST_TIMEOUT_S, MAX_REQUEST_TIMEOUT_S), + reasoning_effort: reasoning_effort(&c.reasoning_effort), + problem: None, + install: "", } } - /// A short line under the name: the version, the model, or the command. + /// A short line under the name: the version, the model, the command, or why it can't start. pub fn detail(&self) -> String { match self.kind { Kind::Api | Kind::Anthropic => format!("{} · {}", self.model, self.base_url), - Kind::Acp => match &self.version { - Some(v) => v.clone(), - None => super::panel::short_command(&self.argv.join(" ")), + Kind::Acp => match (&self.problem, &self.version) { + (Some(p), _) => crate::i18n::format("ui.agent_chat_cannot_start", &[("error", p)]), + (None, Some(v)) => v.clone(), + (None, None) => super::panel::short_command(&self.argv.join(" ")), }, } } + /// What to tell someone starting an agent that was found but didn't start (#9034): the reason, + /// and how to install it again. `None` when nothing was seen wrong. + pub fn problem_hint(&self) -> Option { + let problem = self.problem.as_deref()?; + let program = self.argv.first().map(String::as_str).unwrap_or_default(); + Some(crate::i18n::format( + "ui.agent_chat_reinstall", + &[("agent", &self.name), ("program", program), ("error", problem), ("install", self.install)], + )) + } } /// Every agent the chat can use: the installed ones found, then the ones the user added. @@ -214,6 +247,9 @@ pub fn entries(app: &crate::SolveApp) -> Vec { model: String::new(), key: false, request_timeout_s: DEFAULT_REQUEST_TIMEOUT_S, + reasoning_effort: String::new(), + problem: f.problem.clone(), + install: f.install, })); } out.extend(app.preferences.agents.iter().map(Entry::from_config)); @@ -382,4 +418,23 @@ mod tests { let back: AgentConfig = serde_json::from_value(serde_json::to_value(&saved).unwrap()).unwrap(); assert_eq!(back, saved); } + + #[test] + fn reasoning_effort_defaults_to_empty_and_is_trimmed_and_capped() { + assert!(AgentConfig::default().reasoning_effort.is_empty()); + // An older preferences file has no effort: empty, so nothing is sent. + let old: AgentConfig = serde_json::from_str(r#"{"id":"api-1","kind":"api","base_url":"http://localhost:11434/v1","model":"m"}"#).unwrap(); + assert!(old.reasoning_effort.is_empty()); + assert_eq!(reasoning_effort(" high "), "high"); + assert!(reasoning_effort(" ").is_empty()); + let long = reasoning_effort(&"x".repeat(500)); + assert_eq!(long.chars().count(), MAX_REASONING_EFFORT_LEN); + let saved = AgentConfig { id: "api-2".into(), kind: Kind::Api, reasoning_effort: "max".into(), ..Default::default() }; + let back: AgentConfig = serde_json::from_value(serde_json::to_value(&saved).unwrap()).unwrap(); + assert_eq!(back, saved); + // The entry carries it trimmed; a hand-edited over-long one is capped where used. + assert_eq!(Entry::from_config(&saved).reasoning_effort, "max"); + let wild: AgentConfig = serde_json::from_str(&format!(r#"{{"id":"api-3","kind":"api","reasoning_effort":"{}"}}"#, "y".repeat(500))).unwrap(); + assert_eq!(Entry::from_config(&wild).reasoning_effort.chars().count(), MAX_REASONING_EFFORT_LEN); + } } diff --git a/crates/ui-egui/src/agent_chat/api_client.rs b/crates/ui-egui/src/agent_chat/api_client.rs index 38bc005796..88c38e39af 100644 --- a/crates/ui-egui/src/agent_chat/api_client.rs +++ b/crates/ui-egui/src/agent_chat/api_client.rs @@ -643,6 +643,10 @@ pub struct ApiConfig { pub http: HttpFn, /// How long one model request may take, in seconds (the endpoint's setting). pub request_timeout_s: u64, + /// The reasoning effort to send as `reasoning_effort`; empty sends nothing and the + /// provider's own default applies. A set value wins over the `none` retry (#3817): that + /// retry only runs when no effort was chosen. + pub reasoning_effort: String, } enum Cmd { @@ -650,6 +654,7 @@ enum Cmd { NewChat, SetModel(String), SetRequestTimeout(u64), + SetReasoningEffort(String), } pub struct ApiBackend { @@ -724,6 +729,11 @@ impl ChatBackend for ApiBackend { let _ = c.send(Cmd::SetRequestTimeout(secs)); } } + fn set_reasoning_effort(&mut self, effort: &str) { + if let Some(c) = &self.cmds { + let _ = c.send(Cmd::SetReasoningEffort(effort.to_string())); + } + } fn new_session(&mut self) { // A running turn is cancelled; the worker resets the history once it has ended. self.cancel.store(true, Ordering::Relaxed); @@ -800,6 +810,12 @@ impl Worker { Cmd::Prompt(parts) => self.turn(parts), Cmd::SetModel(m) => self.cfg.model = m, Cmd::SetRequestTimeout(secs) => self.cfg.request_timeout_s = secs, + Cmd::SetReasoningEffort(effort) => { + self.cfg.reasoning_effort = super::agents::reasoning_effort(&effort); + // A new choice starts the refusal dance again: an earlier `none` answer was + // the endpoint's, not the user's, and must not silently override the choice. + self.no_reasoning.set(false); + } Cmd::NewChat => { self.history.clear(); // A new conversation gets a fresh Go session ID for routing and caching. @@ -980,6 +996,12 @@ impl Worker { self.history.len() != before } + /// The reasoning effort the user chose for this endpoint, or `None` to send nothing and let + /// the provider apply its own default. + fn reasoning_effort(&self) -> Option<&str> { + (!self.cfg.reasoning_effort.trim().is_empty()).then(|| self.cfg.reasoning_effort.trim()) + } + fn request(&self) -> Result { // OpenCode Go serves one protocol per model family behind a single base URL. if self.cfg.protocol == Protocol::OpenAi && is_opencode_go(&self.cfg.base_url) { @@ -989,11 +1011,13 @@ impl Worker { let (status, v) = self.post(&body)?; // Some OpenAI models refuse function tools on this endpoint unless the reasoning effort is // "none" (HTTP 400 naming `reasoning_effort`): ask again once with it, and keep it for the - // next requests. Anthropic has no such parameter. + // next requests. Anthropic has no such parameter. A reasoning effort the user chose wins: + // the refusal is shown instead of being replaced silently (#10241). let refused = self.cfg.protocol == Protocol::OpenAi && status == 400 && !self.tools.is_empty() && !self.no_reasoning.get() + && self.reasoning_effort().is_none() && http_error(status, &v).contains("reasoning_effort"); if refused { self.no_reasoning.set(true); @@ -1021,15 +1045,19 @@ impl Worker { let mut first_err: Option = None; for protocol in order { let mut body = self.go_body(protocol); - if protocol == GoProtocol::Chat && !self.tools.is_empty() && self.no_reasoning.get() { + if protocol == GoProtocol::Chat && !self.tools.is_empty() && self.no_reasoning.get() && self.reasoning_effort().is_none() { body["reasoning_effort"] = json!("none"); } let (status, v) = self.go_post(protocol, &body)?; if protocol == GoProtocol::Chat { // As on plain OpenAI endpoints: ask again once with reasoning effort - // "none" when the refusal names it. - let refused = - status == 400 && !self.tools.is_empty() && !self.no_reasoning.get() && http_error(status, &v).contains("reasoning_effort"); + // "none" when the refusal names it — unless the user chose an effort, whose + // refusal is shown instead (#10241). + let refused = status == 400 + && !self.tools.is_empty() + && !self.no_reasoning.get() + && self.reasoning_effort().is_none() + && http_error(status, &v).contains("reasoning_effort"); if refused { self.no_reasoning.set(true); body["reasoning_effort"] = json!("none"); @@ -1143,9 +1171,14 @@ impl Worker { messages.push(json!({"role": "system", "content": super::acp::SYSTEM_NOTE})); messages.extend(self.history.iter().cloned()); let mut body = json!({"model": self.cfg.model, "messages": messages}); + if let Some(effort) = self.reasoning_effort() { + // The user's choice goes out on every request, ahead of any `none` the + // endpoint once asked for (#10241). + body["reasoning_effort"] = json!(effort); + } if !self.tools.is_empty() { body["tools"] = Value::Array(self.tools.clone()); - if self.no_reasoning.get() { + if self.no_reasoning.get() && self.reasoning_effort().is_none() { body["reasoning_effort"] = json!("none"); } } @@ -1861,6 +1894,7 @@ mod tests { key: None, http, request_timeout_s: 300, + reasoning_effort: String::new(), }; let ctx = StartContext { cwd: std::env::temp_dir(), bridge: None, tools: None, wake: Box::new(|| {}) }; let mut b = ApiBackend::start(cfg, ctx).unwrap(); @@ -1911,6 +1945,7 @@ mod tests { key: None, http, request_timeout_s: 300, + reasoning_effort: String::new(), }; let ctx = StartContext { cwd: std::env::temp_dir(), bridge: None, tools: None, wake: Box::new(|| {}) }; let mut b = ApiBackend::start(cfg, ctx).unwrap(); @@ -1964,6 +1999,7 @@ mod tests { key: None, http, request_timeout_s: 300, + reasoning_effort: String::new(), }; let ctx = StartContext { cwd: std::env::temp_dir(), bridge: None, tools: None, wake: Box::new(|| {}) }; let mut b = ApiBackend::start(cfg, ctx).unwrap(); @@ -2010,6 +2046,7 @@ mod tests { key: None, http, request_timeout_s: 300, + reasoning_effort: String::new(), }; let ctx = StartContext { cwd: std::env::temp_dir(), bridge: None, tools: None, wake: Box::new(|| {}) }; let mut b = ApiBackend::start(cfg, ctx).unwrap(); @@ -2093,6 +2130,7 @@ mod tests { key: None, http, request_timeout_s: 300, + reasoning_effort: String::new(), } } @@ -2166,6 +2204,7 @@ mod tests { key: None, http, request_timeout_s: 300, + reasoning_effort: String::new(), }; let mut b = ApiBackend::start(cfg, go_ctx()).unwrap(); b.prompt(&[PromptPart::Text("hi".into())]); @@ -2484,6 +2523,7 @@ mod tests { key: None, http, request_timeout_s: 300, + reasoning_effort: String::new(), }; let mut b = ApiBackend::start(cfg, go_ctx()).unwrap(); b.prompt(&[PromptPart::Text("hi".into())]); @@ -2556,6 +2596,7 @@ mod tests { key: None, http, request_timeout_s: 300, + reasoning_effort: String::new(), }; let ctx = StartContext { cwd: std::env::temp_dir(), bridge: None, tools: None, wake: Box::new(|| {}) }; let mut b = ApiBackend::start(cfg, ctx).unwrap(); @@ -2585,6 +2626,7 @@ mod tests { key: Some("k".into()), http, request_timeout_s: 300, + reasoning_effort: String::new(), }; let ctx = StartContext { cwd: std::env::temp_dir(), bridge: None, tools: None, wake: Box::new(|| {}) }; assert!(ApiBackend::start(cfg, ctx).is_err()); @@ -2593,6 +2635,11 @@ mod tests { /// A worker with one function tool whose endpoint answers each POST with the next scripted /// (status, body); the requests it received are returned too. fn scripted_worker(answers: Vec<(u16, Value)>) -> (Worker, Arc>>) { + scripted_worker_effort(answers, "") + } + + /// [`scripted_worker`] with the endpoint's reasoning effort set (empty: send nothing). + fn scripted_worker_effort(answers: Vec<(u16, Value)>, effort: &str) -> (Worker, Arc>>) { let answers = std::sync::Mutex::new(answers.into_iter().rev().collect::>()); let seen = Arc::new(std::sync::Mutex::new(Vec::::new())); let log = seen.clone(); @@ -2609,6 +2656,7 @@ mod tests { key: Some("sk-secret".into()), http, request_timeout_s: 300, + reasoning_effort: effort.to_string(), }; let (events, _rx) = channel(); let w = Worker { @@ -2660,6 +2708,37 @@ mod tests { assert_eq!(seen.lock().unwrap().len(), 1); } + /// A reasoning effort the user chose goes out as `reasoning_effort` on every request; empty + /// sends nothing, so existing agents are untouched (#10241). + #[test] + fn a_chosen_reasoning_effort_goes_out_on_every_request() { + let ok = json!({"choices": [{"message": {"role": "assistant", "content": "Done."}}]}); + let (w, seen) = scripted_worker_effort(vec![(200, ok.clone()), (200, ok.clone())], "high"); + assert_eq!(w.request().unwrap().assistant, ok["choices"][0]["message"]); + assert_eq!(w.request().unwrap().assistant, ok["choices"][0]["message"]); + let sent = seen.lock().unwrap(); + assert_eq!(sent.len(), 2); + assert_eq!(sent[0]["reasoning_effort"], "high"); + assert_eq!(sent[1]["reasoning_effort"], "high"); + drop(sent); + // Empty effort: nothing is sent at all. + let (w, seen) = scripted_worker(vec![(200, ok)]); + assert!(w.request().is_ok()); + assert!(seen.lock().unwrap()[0].get("reasoning_effort").is_none()); + } + + /// A chosen effort wins over the #3817 `none` retry: the endpoint's refusal of the effort is + /// shown to the user after exactly one request, never silently replaced by `none` (#10241). + #[test] + fn a_chosen_reasoning_effort_wins_over_the_none_retry() { + let (w, seen) = scripted_worker_effort(vec![refusal()], "max"); + let e = w.request().unwrap_err(); + assert!(e.starts_with("HTTP 400") && e.contains("reasoning_effort"), "{e}"); + let sent = seen.lock().unwrap(); + assert_eq!(sent.len(), 1, "the refusal is shown, not replayed with none"); + assert_eq!(sent[0]["reasoning_effort"], "max"); + } + #[test] fn mcp_tools_become_anthropic_tools_without_crash_reports() { let list = json!({"tools": [ @@ -2742,6 +2821,7 @@ mod tests { key: Some("sk-secret".into()), http, request_timeout_s: 300, + reasoning_effort: String::new(), }; let ctx = StartContext { cwd: std::env::temp_dir(), bridge: None, tools: None, wake: Box::new(|| {}) }; let mut b = ApiBackend::start(cfg, ctx).unwrap(); @@ -2791,6 +2871,7 @@ mod tests { key: Some("sk-secret".into()), http, request_timeout_s: 300, + reasoning_effort: String::new(), }; let ctx = StartContext { cwd: std::env::temp_dir(), bridge: None, tools: None, wake: Box::new(|| {}) }; let mut b = ApiBackend::start(cfg, ctx).unwrap(); @@ -2841,6 +2922,7 @@ mod tests { key: Some("sk-secret".into()), http, request_timeout_s: 300, + reasoning_effort: String::new(), }; let ctx = StartContext { cwd: std::env::temp_dir(), bridge: None, tools: None, wake: Box::new(|| {}) }; let mut b = ApiBackend::start(cfg, ctx).unwrap(); diff --git a/crates/ui-egui/src/agent_chat/backend.rs b/crates/ui-egui/src/agent_chat/backend.rs index afa1c44e9e..c7976a8d51 100644 --- a/crates/ui-egui/src/agent_chat/backend.rs +++ b/crates/ui-egui/src/agent_chat/backend.rs @@ -147,6 +147,8 @@ pub trait ChatBackend { fn set_mode(&mut self, _mode: &str) {} /// An endpoint's new request timeout, in seconds, from the next request on. fn set_request_timeout(&mut self, _secs: u64) {} + /// An endpoint's new reasoning effort (empty: send nothing), from the next request on. + fn set_reasoning_effort(&mut self, _effort: &str) {} fn set_config_option(&mut self, _id: &str, _value: &str) {} /// Its process, for status and tests. fn pid(&self) -> Option { diff --git a/crates/ui-egui/src/agent_chat/discover.rs b/crates/ui-egui/src/agent_chat/discover.rs index ace05c3c29..24792f42c5 100644 --- a/crates/ui-egui/src/agent_chat/discover.rs +++ b/crates/ui-egui/src/agent_chat/discover.rs @@ -187,6 +187,11 @@ pub struct Found { pub argv: Vec, pub version: Option, pub login: Login, + /// Why its program doesn't start (#9034): asked for `--version`, it failed without printing + /// one, as the `opencode.exe` of an npm install whose setup script didn't run does on Windows. + pub problem: Option, + /// Its one-line install hint, to reinstall it with. + pub install: &'static str, } #[derive(Clone, Debug, Default)] @@ -288,8 +293,24 @@ pub(super) fn command_for(program: &Path) -> Command { cmd } -/// `program --version`'s first line, or `None` after `limit` (the probe is then stopped). -fn version(program: &Path, limit: Duration) -> Option { +/// What `program --version` showed. +#[derive(Debug, Default, PartialEq)] +struct Probe { + /// Its version number, from the first line it printed. + version: Option, + /// Why it can't start: the system refused to start it, or it failed and printed nothing but + /// an error. A probe that runs out of time says nothing either way (a slow machine). + problem: Option, +} + +/// How long an agent is given to answer `--version`. +const PROBE_LIMIT: Duration = Duration::from_secs(3); + +/// The longest reason kept from a failed probe. +const PROBLEM_CHARS: usize = 300; + +/// Ask `program --version`; it is stopped after `limit`. +fn probe(program: &Path, limit: Duration) -> Probe { let mut cmd = command_for(program); #[cfg(windows)] { @@ -297,23 +318,50 @@ fn version(program: &Path, limit: Duration) -> Option { // CREATE_NO_WINDOW: probing a console agent doesn't flash a terminal window. cmd.creation_flags(0x0800_0000); } - let mut child = cmd.arg("--version").stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().ok()?; + let mut child = match cmd.arg("--version").stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn() { + Ok(child) => child, + // Windows: "not a valid Win32 application" (193), "not compatible with the version of + // Windows you're running" (216); elsewhere: not allowed to run it, an unknown format. + // Windows leaves the program's place in its message as `%1`. + Err(e) => return Probe { version: None, problem: Some(cut(&e.to_string().replace("%1", &program.display().to_string()))) }, + }; let start = Instant::now(); - loop { + let status = loop { match child.try_wait() { - Ok(Some(_)) => break, + Ok(Some(status)) => break status, Ok(None) if start.elapsed() < limit => std::thread::sleep(Duration::from_millis(20)), _ => { let _ = child.kill(); let _ = child.wait(); - return None; + return Probe::default(); } } + }; + let out = read_capped(child.stdout.take()); + if let Some(line) = out.lines().map(str::trim).find(|l| !l.is_empty()) { + return Probe { version: Some(version_of(line)), problem: None }; } - let mut out = String::new(); - std::io::Read::read_to_string(&mut child.stdout.take()?, &mut out).ok()?; - let line = out.lines().map(str::trim).find(|l| !l.is_empty())?; - Some(version_of(line)) + if status.success() { + return Probe::default(); + } + // Through a `.cmd` shim, Windows' own refusal arrives on stderr with a failing exit code. + let err = read_capped(child.stderr.take()); + let reason = err.lines().map(str::trim).find(|l| !l.is_empty()).map(str::to_string).unwrap_or_else(|| status.to_string()); + Probe { version: None, problem: Some(cut(&reason)) } +} + +/// What a finished probe wrote on a pipe, up to 64 KiB. +fn read_capped(pipe: Option) -> String { + let mut bytes = Vec::new(); + if let Some(p) = pipe { + let _ = std::io::Read::read_to_end(&mut p.take(64 * 1024), &mut bytes); + } + String::from_utf8_lossy(&bytes).into_owned() +} + +/// `text`, cut short. +fn cut(text: &str) -> String { + text.chars().take(PROBLEM_CHARS).collect() } /// The version number in a `--version` line (`codex-cli 0.162.1`, `2.1.295 (Claude Code)`, @@ -342,10 +390,10 @@ fn login_of(k: &Known, home: Option<&Path>) -> Login { /// Look for every known agent. Blocking (each probe has a time limit): run it off the UI thread. pub fn detect() -> Detection { - detect_in(&search_dirs(), home().as_deref()) + detect_in(&search_dirs(), home().as_deref(), PROBE_LIMIT) } -fn detect_in(dirs: &[PathBuf], home: Option<&Path>) -> Detection { +fn detect_in(dirs: &[PathBuf], home: Option<&Path>, limit: Duration) -> Detection { let t0 = Instant::now(); let mut d = Detection::default(); for k in KNOWN { @@ -358,8 +406,8 @@ fn detect_in(dirs: &[PathBuf], home: Option<&Path>) -> Detection { let login = login_of(k, home); let mut argv = vec![path.to_string_lossy().into_owned()]; argv.extend(k.args.iter().map(|a| a.to_string())); - let version = if k.reports_version { version(&path, Duration::from_secs(3)) } else { None }; - d.found.push(Found { id: k.id, name: k.name, version, argv, login }); + let Probe { version, problem } = if k.reports_version { probe(&path, limit) } else { Probe::default() }; + d.found.push(Found { id: k.id, name: k.name, version, argv, login, problem, install: k.install }); } d.took_ms = u64::try_from(t0.elapsed().as_millis()).unwrap_or(u64::MAX); d @@ -544,7 +592,7 @@ mod tests { }; let ids = |d: &Detection| (d.found.iter().map(|f| f.id).collect::>(), d.missing.iter().map(|m| m.id).collect::>()); - let none = detect_in(std::slice::from_ref(&root), None); + let none = detect_in(std::slice::from_ref(&root), None, PROBE_LIMIT); let (found, missing) = ids(&none); assert!(found.is_empty()); assert!(missing.contains(&"antigravity") && !missing.contains(&"gemini"), "{missing:?}"); @@ -554,7 +602,7 @@ mod tests { run("gemini", "echo gemini 0.64.0"); // A server that would take seconds to answer `--version`, which Antigravity is never asked. run("agy_acp_server.par", "sleep 30"); - let both = detect_in(std::slice::from_ref(&root), None); + let both = detect_in(std::slice::from_ref(&root), None, PROBE_LIMIT); let (found, missing) = ids(&both); assert_eq!(found, ["antigravity", "gemini"]); assert!(!missing.contains(&"antigravity") && !missing.contains(&"gemini"), "{missing:?}"); @@ -573,6 +621,73 @@ mod tests { assert_eq!(KNOWN.first().map(|k| k.id), Some("claude"), "the Claude Code adapter is the default"); } + /// #9034: npm installed OpenCode without running its setup script, so the `opencode.exe` its + /// `.cmd` shim starts is one Windows refuses. It is still found (its shim is there), with + /// the system's reason and the command to install it again, while an agent that answers + /// `--version`, or ends without a word but successfully, has no problem. + #[test] + fn an_agent_that_does_not_start_is_found_with_the_reason() { + let root = std::env::temp_dir().join(format!("sc-agent-broken-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + #[cfg(windows)] + { + // npm's layout: the shim in the prefix, the program under node_modules. + let bin = root.join("node_modules").join("opencode-ai").join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + std::fs::write(bin.join("opencode.exe"), "not a program for this computer").unwrap(); + std::fs::write(root.join("opencode.cmd"), "@ECHO off\r\n\"%~dp0\\node_modules\\opencode-ai\\bin\\opencode.exe\" %*\r\n").unwrap(); + // Started directly, the system itself refuses it. + std::fs::write(root.join("copilot.exe"), "not a program either").unwrap(); + std::fs::write(root.join("grok.cmd"), "@echo off\r\nexit /b 3\r\n").unwrap(); + std::fs::write(root.join("qwen.cmd"), "@echo off\r\necho 0.1.2\r\n").unwrap(); + std::fs::write(root.join("goose.cmd"), "@echo off\r\n").unwrap(); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let run = |name: &str, body: &str| { + let path = root.join(name); + std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + }; + run("opencode", "echo 'cannot execute binary file: Exec format error' >&2; exit 126"); + // Not allowed to run: the system refuses it. + std::fs::write(root.join("copilot"), "not a program").unwrap(); + std::fs::set_permissions(root.join("copilot"), std::fs::Permissions::from_mode(0o644)).unwrap(); + run("grok", "exit 3"); + run("qwen", "echo 0.1.2"); + run("goose", "true"); + } + // A generous limit: on a loaded machine `cmd` alone can take longer than three seconds to + // start, and a probe that runs out of time reports nothing. + let d = detect_in(std::slice::from_ref(&root), None, Duration::from_secs(60)); + let found = |id: &str| d.found.iter().find(|f| f.id == id).unwrap_or_else(|| panic!("{id} not found: {:?}", d.found)); + + let opencode = found("opencode"); + assert_eq!(opencode.version, None); + assert!(opencode.problem.as_deref().is_some_and(|p| !p.trim().is_empty()), "{opencode:?}"); + assert_eq!(opencode.install, "npm install -g opencode-ai"); + assert_eq!(opencode.argv.get(1).map(String::as_str), Some("acp"), "still offered with its ACP arguments"); + let copilot = found("copilot"); + assert!(copilot.problem.as_deref().is_some_and(|p| !p.trim().is_empty()), "{copilot:?}"); + #[cfg(windows)] + assert!( + copilot.problem.as_deref().is_some_and(|p| (p.contains("216") || p.contains("193")) && p.contains("copilot.exe") && !p.contains("%1")), + "the system's reason, naming the program: {copilot:?}" + ); + // Silent but failing: the exit status is the reason. + let grok = found("grok"); + assert!(grok.problem.as_deref().is_some_and(|p| p.contains('3')), "{grok:?}"); + + let qwen = found("qwen"); + assert_eq!((qwen.version.as_deref(), qwen.problem.as_deref()), (Some("0.1.2"), None)); + let goose = found("goose"); + assert_eq!((goose.version.as_deref(), goose.problem.as_deref()), (None, None)); + assert!(d.found.iter().all(|f| f.problem.as_ref().is_none_or(|p| p.chars().count() <= PROBLEM_CHARS))); + let _ = std::fs::remove_dir_all(root); + } + /// A launcher can start SolidCraft with a PATH that lacks Node (#9297). On Windows, Node's /// install folders are searched after it, so `node` is found, and an agent started with these /// folders as its PATH (`command_for`) finds it too. @@ -623,7 +738,7 @@ mod tests { let agent = find_in("solidcraft-test-agent", &search_dirs()).unwrap(); // Generous limits: both answer at once, but a whole test run on a loaded machine // can keep `env`, the shell and the script from starting within 3 s. - assert_eq!(version(&agent, Duration::from_secs(20)).as_deref(), Some("1.2.3")); + assert_eq!(probe(&agent, Duration::from_secs(20)).version.as_deref(), Some("1.2.3")); let mut p = AgentProcess::spawn(&[agent.to_string_lossy().into_owned()], &root, &[], || {}).unwrap(); let message = serde_json::json!({"id": 1, "method": "initialize"}); p.send(&message); diff --git a/crates/ui-egui/src/agent_chat/mod.rs b/crates/ui-egui/src/agent_chat/mod.rs index 30fb5b07f5..b1554a06c8 100644 --- a/crates/ui-egui/src/agent_chat/mod.rs +++ b/crates/ui-egui/src/agent_chat/mod.rs @@ -320,6 +320,9 @@ impl Loading { pub struct AgentChat { /// The panel is shown (View menu, the application bar's chat button, Ctrl+Shift+A). pub open: bool, + /// Only while dispatching this conversation's own admitted tool request. Window state: + /// creating/opening a design may carry the conversation, but a person's tab switch may not. + pub(crate) following_document: bool, pub items: Vec, pub input: String, /// The viewer, open on one message's pictures. @@ -419,6 +422,7 @@ pub struct AgentChat { /// Settings → AI: the Add agent dialog and connection tests. pub settings: settings::State, /// Latest authentication evidence from a connection test or a chat, per agent; never saved. + /// It belongs to the window, not to one tab's conversation. login_results: std::collections::HashMap, /// Desktop host hook: start the endpoint on demand. pub control_starter: Option, @@ -443,11 +447,14 @@ impl AgentChat { pub(crate) fn swap_document(&mut self, other: &mut Self) { std::mem::swap(self, other); std::mem::swap(&mut self.open, &mut other.open); + std::mem::swap(&mut self.following_document, &mut other.following_document); std::mem::swap(&mut self.settings, &mut other.settings); std::mem::swap(&mut self.control_starter, &mut other.control_starter); std::mem::swap(&mut self.models_cache, &mut other.models_cache); std::mem::swap(&mut self.window_placed, &mut other.window_placed); std::mem::swap(&mut self.connect_at_start, &mut other.connect_at_start); + // A sign-in proven in one tab holds for that agent in every tab (#9293). + std::mem::swap(&mut self.login_results, &mut other.login_results); #[cfg(not(target_arch = "wasm32"))] { std::mem::swap(&mut self.detection, &mut other.detection); @@ -936,6 +943,11 @@ pub fn connect(app: &mut SolveApp, ctx: &egui::Context) { } else if cli.is_none() { chat.notice(tr("ui.agent_chat_no_cli"), true); } + // Found, but it failed when asked its version (#9034): say why and how to reinstall it before + // the system's own message arrives. It is still started, in case it only lacks `--version`. + if let Some(hint) = entry.problem_hint() { + chat.notice(hint, true); + } let bridge = cli.zip(chat.tools_port).map(|(cli, port)| acp::McpBridge { cli, port }); chat.tools_available = Some(bridge.is_some()); let wake = ctx.clone(); @@ -955,6 +967,7 @@ pub fn connect(app: &mut SolveApp, ctx: &egui::Context) { key, http, request_timeout_s: entry.request_timeout_s, + reasoning_effort: entry.reasoning_effort.clone(), }, start, ) @@ -1044,6 +1057,66 @@ pub fn shutdown(app: &mut SolveApp) { crate::documents::disconnect_background_chats(app, true); } +/// Mark the origin only for this synchronous dispatch. Deferred work carries the same trusted +/// turn flag and enters here when it applies; JSON parameters cannot opt into continuation. +pub(crate) fn with_tool_request( + app: &mut SolveApp, + active: Option<&Arc>, + run: impl FnOnce(&mut SolveApp) -> T, +) -> T { + let own = active.is_some_and(|active| { + active.load(std::sync::atomic::Ordering::Relaxed) && app.agent_chat.turn.as_ref().is_some_and(|turn| Arc::ptr_eq(active, &turn.active)) + }); + // A batch may replace the design and then roll back to its checkpoint. Keep the turn's + // original undo boundary when that happens, including edits made before the batch. + let before = app.agent_chat.turn.as_ref().filter(|_| own).map(|t| (app.session.doc.clone(), t.start_doc.clone(), t.start_undo)); + let previous = std::mem::replace(&mut app.agent_chat.following_document, own); + let result = run(app); + app.agent_chat.following_document = previous; + if let Some((doc, start_doc, start_undo)) = before + && Arc::ptr_eq(&doc, &app.session.doc) + && let Some(turn) = app.agent_chat.turn.as_mut() + && active.is_some_and(|active| Arc::ptr_eq(active, &turn.active)) + { + turn.start_doc = start_doc; + turn.start_undo = start_undo; + } + result +} + +/// The agent's replacement succeeded. Keep its backend, transcript, queue, limits and validity +/// flag; subsequent edits undo in this design and picked geometry belongs to the old one. +pub(crate) fn continue_in_document(app: &mut SolveApp) { + if let Some(turn) = app.agent_chat.turn.as_mut() { + turn.start_doc = app.session.doc.clone(); + turn.start_undo = app.session.undo.len(); + } + app.agent_chat.picked.clear(); + app.agent_chat.picked_context = Context::default(); + app.agent_chat.selected_count = None; +} + +/// A newly opened tab has no conversation to displace. Move the initiating chat into it, +/// grouping the previous design's completed edits before its session is parked. +pub(crate) fn follow_new_tab(app: &mut SolveApp, switch: impl FnOnce(&mut SolveApp)) { + if !app.agent_chat.following_document { + switch(app); + return; + } + if let Some(turn) = app.agent_chat.turn.as_ref() { + let steps = + merge_undo(&mut app.session.undo, &turn.start_doc, turn.start_undo, &trf("ui.agent_chat_undo_label", &[("message", &turn.label)])); + if steps > 0 { + app.agent_chat.push(Item::Changes { steps, doc: app.session.doc.clone(), undone: false }); + } + } + let mut chat = AgentChat::default(); + app.agent_chat.swap_document(&mut chat); + switch(app); + app.agent_chat.swap_document(&mut chat); + continue_in_document(app); +} + /// A tab switch stops this turn before its session leaves the app. Keep the backend's own /// conversation, drafts and queue, but do not send that queue automatically on returning. pub(crate) fn leave_document(app: &mut SolveApp) { @@ -2096,6 +2169,13 @@ pub fn control(app: &mut SolveApp, ctx: &egui::Context, p: &Value) -> Result app.agent_chat.settings.add = None, } } + // The same dialog on an agent you added (by id), filled with what it holds; its key stays hidden. + if let Some(id) = p.get("edit_dialog") { + let id = id.as_str().ok_or("edit_dialog must be the id of an agent you added")?; + if !settings::open_edit(app, id) { + return Err(format!("edit_dialog: no added agent `{id}`")); + } + } if let Some(id) = p.get("default_agent").and_then(Value::as_str) { app.preferences.agent_default = id.to_string(); } @@ -2225,7 +2305,7 @@ pub fn status(app: &SolveApp) -> Value { #[cfg(not(target_arch = "wasm32"))] let detection = c.detection.as_ref().map(|d| { json!({ - "found": d.found.iter().map(|f| json!({"id": f.id, "name": f.name, "argv": f.argv, "version": f.version, "login": f.login.id()})).collect::>(), + "found": d.found.iter().map(|f| json!({"id": f.id, "name": f.name, "argv": f.argv, "version": f.version, "login": f.login.id(), "problem": f.problem})).collect::>(), "missing": d.missing.iter().map(|m| json!({"id": m.id, "install": m.install})).collect::>(), "ms": d.took_ms, }) @@ -2242,7 +2322,7 @@ pub fn status(app: &SolveApp) -> Value { "openAtStart": app.preferences.agent_chat_open_at_start, "lastAgent": app.preferences.agent_last, "permissions": app.preferences.ai_permissions.id(), - "agents": agents::entries(app).iter().map(|e| json!({"id": e.id, "name": e.name, "kind": e.kind.id(), "found": e.found, "version": e.version, "login": e.login, "request_timeout_s": e.kind.is_api().then_some(e.request_timeout_s)})).collect::>(), + "agents": agents::entries(app).iter().map(|e| json!({"id": e.id, "name": e.name, "kind": e.kind.id(), "found": e.found, "version": e.version, "login": e.login, "problem": e.problem, "request_timeout_s": e.kind.is_api().then_some(e.request_timeout_s)})).collect::>(), "currentAgent": agents::current(app).map(|e| e.id), "models": c.models.iter().map(|m| json!({"id": m.id, "name": m.name, "description": m.description})).collect::>(), "model": c.model, diff --git a/crates/ui-egui/src/agent_chat/panel.rs b/crates/ui-egui/src/agent_chat/panel.rs index 9700911306..fd264d0061 100644 --- a/crates/ui-egui/src/agent_chat/panel.rs +++ b/crates/ui-egui/src/agent_chat/panel.rs @@ -211,7 +211,7 @@ fn floating(app: &mut SolveApp, ui: &mut egui::Ui) { let frame = ui_kit::Panel::feature_frame(&ctx, app.ui.layout, side); let inner = (vec2(width, height) - frame.total_margin().sum()).max(egui::Vec2::ZERO); let panel = ui_kit::Panel::new(tr("ui.assistant"), Id::new("sc_agent_chat_floating"), anchor, available, side, frame).width(width); - if let Some(card) = panel.window(&ctx).fixed_size(inner).show(&ctx, |ui| { + if let Some(card) = panel.fixed_window(&ctx, inner).show(&ctx, |ui| { ui.set_min_size(inner); ui.set_max_size(inner); ui.set_clip_rect(ui.clip_rect().intersect(Rect::from_min_size(ui.max_rect().min, inner))); @@ -804,17 +804,19 @@ fn desktop_body(app: &mut SolveApp, ui: &mut egui::Ui) { let state = app.agent_chat.state(); if app.agent_chat.confirm_new { ui_kit::scope(ui, |ui| { - ui.set_width(ui.available_width()); - ui_kit::text(ui, tr("ui.agent_chat_new_confirm"), ui_kit::Text::Report("new_chat", false)); - ui.horizontal(|ui| { - let yes = ui_kit::button(ui, tr("ui.agent_chat_new"), ui_kit::Button::Normal); - crate::scenario::publish_handle("agent_chat:new_confirm", yes.rect.center()); - if yes.clicked() { - new_chat(app); - } - if ui_kit::button(ui, tr("ui.agent_chat_cancel"), ui_kit::Button::Normal).clicked() { - app.agent_chat.confirm_new = false; - } + inset(ui, |ui| { + ui.set_width(ui.available_width()); + ui_kit::text(ui, tr("ui.agent_chat_new_confirm"), ui_kit::Text::Report("new_chat", false)); + ui.horizontal(|ui| { + let yes = ui_kit::button(ui, tr("ui.agent_chat_new"), ui_kit::Button::Normal); + crate::scenario::publish_handle("agent_chat:new_confirm", yes.rect.center()); + if yes.clicked() { + new_chat(app); + } + if ui_kit::button(ui, tr("ui.agent_chat_cancel"), ui_kit::Button::Normal).clicked() { + app.agent_chat.confirm_new = false; + } + }); }); }); } @@ -826,7 +828,7 @@ fn desktop_body(app: &mut SolveApp, ui: &mut egui::Ui) { .auto_shrink([false, true]) .show(ui, |ui| picker(app, ui)); if state == State::NeedsAuth { - auth_box(app, ui); + inset(ui, |ui| auth_box(app, ui)); } // The input sits at the bottom of the panel and grows upwards: a long message makes its box // taller up to `edit_max`, then it scrolls, and the conversation takes what is left above. It @@ -1068,7 +1070,7 @@ fn switcher(app: &mut SolveApp, ui: &mut egui::Ui) { add = true; } if ui_kit::list_picker(ui, "settings", tr("ui.ai_settings"), false).clicked() { - crate::prefs::open_at(app, crate::prefs::PAGE_AI); + crate::prefs::open_at(app, crate::prefs::PAGE_ASSISTANT); } }) }) @@ -1444,6 +1446,13 @@ mod config_ui_tests { } } +/// What is drawn under the agent switcher keeps the switcher's side margin (#10172): it starts +/// where the switcher's fields start, not at the panel's edge. +#[cfg(not(target_arch = "wasm32"))] +fn inset(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui) -> R) -> R { + egui::Frame::NONE.inner_margin(egui::Margin::symmetric(Tokens::get().space_12 as i8, 0)).show(ui, add).inner +} + /// Before the first connection: what's installed, how to install the rest, and Add agent. #[cfg(not(target_arch = "wasm32"))] fn picker(app: &mut SolveApp, ui: &mut egui::Ui) { @@ -1451,6 +1460,12 @@ fn picker(app: &mut SolveApp, ui: &mut egui::Ui) { if !open { return; } + inset(ui, |ui| agent_list(app, ui)); +} + +/// [`picker`]'s content: the agents found, Refresh and Add agent, and what isn't installed. +#[cfg(not(target_arch = "wasm32"))] +fn agent_list(app: &mut SolveApp, ui: &mut egui::Ui) { let all = agents::entries(app); let current = agents::current(app); ui.horizontal(|ui| { @@ -1793,7 +1808,7 @@ fn conversation(app: &mut SolveApp, ui: &mut egui::Ui) { if agents::entries(app).is_empty() && app.agent_chat.detecting.is_none() { ui_kit::assistant_empty(ui, tr("ui.agent_chat_setup_title"), tr("ui.agent_chat_setup_body"), |ui| { if ui_kit::primary_button(ui, tr("ui.agent_chat_open_settings")).clicked() { - crate::prefs::open_at(app, crate::prefs::PAGE_AI); + crate::prefs::open_at(app, crate::prefs::PAGE_ASSISTANT); } }); } else { diff --git a/crates/ui-egui/src/agent_chat/settings.rs b/crates/ui-egui/src/agent_chat/settings.rs index 86b78a01ec..98527212b7 100644 --- a/crates/ui-egui/src/agent_chat/settings.rs +++ b/crates/ui-egui/src/agent_chat/settings.rs @@ -30,7 +30,13 @@ pub struct AddState { pub key: String, /// Seconds one model request may take. pub request_timeout_s: u64, + /// Optional reasoning effort sent as `reasoning_effort`; empty sends nothing. + pub reasoning_effort: String, pub error: Option, + /// Editing this added agent (its id) instead of adding a new one. + pub editing: Option, + /// Editing an endpoint whose key is in the keychain: an empty key field keeps it. + pub saved_key: bool, test: Option, } @@ -169,6 +175,41 @@ pub fn open_add(app: &mut SolveApp) { }); } +/// Open the agent dialog on an agent you added, filled with what it holds. The key is never read +/// back: its field stays empty, and saving with it empty keeps the stored key. Returns false when +/// `id` is not an added agent (found agents have nothing of yours to edit). +pub fn open_edit(app: &mut SolveApp, id: &str) -> bool { + let Some(c) = app.preferences.agents.iter().find(|c| c.id == id) else { return false }; + let mut s = AddState { + name: c.name.clone(), + request_timeout_s: c.request_timeout_s.clamp(agents::MIN_REQUEST_TIMEOUT_S, agents::MAX_REQUEST_TIMEOUT_S), + reasoning_effort: c.reasoning_effort.clone(), + editing: Some(c.id.clone()), + ..Default::default() + }; + if c.kind.is_api() { + let protocol = if c.kind == Kind::Anthropic { Protocol::Anthropic } else { Protocol::OpenAi }; + let presets = agents::presets(protocol); + // The Ollama preset stays chosen even if its URL was changed, as `ollama` records. + let preset = if protocol == Protocol::OpenAi && c.ollama { + Some(0) + } else { + presets.iter().position(|p| agents::normalize_url(p.base_url).ok().as_deref() == Some(c.base_url.as_str())) + }; + s.kind = AddKind::Api; + s.protocol = protocol; + s.preset = preset.unwrap_or(presets.len()); + s.base_url = c.base_url.clone(); + s.model = c.model.clone(); + s.saved_key = c.key; + } else { + s.kind = AddKind::Custom; + s.command = c.command.clone(); + } + app.agent_chat.settings.add = Some(s); + true +} + /// Take `index` from the protocol's presets, or make the URL empty for a custom endpoint. pub(super) fn choose_preset(s: &mut AddState, protocol: Protocol, index: usize) { s.protocol = protocol; @@ -221,6 +262,13 @@ fn timeout_field(ui: &mut egui::Ui, secs: &mut u64, handle: &str) -> bool { r.changed() } +/// The free-text reasoning effort field with its hover hint; true when the text changed. +fn effort_field(ui: &mut egui::Ui, effort: &mut String) -> bool { + ui_kit::cell_editor(ui, ui.id().with("ai_reasoning_effort"), effort, tr("ui.ai_reasoning_effort_field_hint"), false) + .on_hover_text(tr("ui.ai_reasoning_effort_hint")) + .changed() +} + fn test_line(ui: &mut egui::Ui, test: &Option<&Test>) { match test { Some(Test::Running(_)) => { @@ -273,7 +321,7 @@ pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) { if !cfg!(target_arch = "wasm32") && app.preferences.ai_features { #[cfg(not(target_arch = "wasm32"))] { - let browsing = !crate::prefs_search::querying(ui) || app.prefs_window.section == crate::prefs::PAGE_AI; + let browsing = !crate::prefs_search::querying(ui) || app.prefs_window.section == crate::prefs::PAGE_ASSISTANT; if browsing && app.agent_chat.detection.is_none() && app.agent_chat.detecting.is_none() { refresh(app, &ui.ctx().clone()); } @@ -404,7 +452,9 @@ fn agents_section(app: &mut SolveApp, ui: &mut egui::Ui) { let default = if all.iter().any(|e| &e.id == d) { Some(d.clone()) } else { all.first().map(|e| e.id.clone()) }; let mut remove: Option = None; let mut test: Option = None; + let mut edit: Option = None; let mut retimed: Option<(String, u64)> = None; + let mut reeffort: Option<(String, String)> = None; let t = crate::theme::Tokens::get(); ui_kit::settings_section(ui, "agents", tr("ui.ai_agents"), |ui| { if app.agent_chat.detecting.is_some() { @@ -443,6 +493,13 @@ fn agents_section(app: &mut SolveApp, ui: &mut egui::Ui) { if test_button.on_hover_text(tr(tip)).clicked() { test = Some(entry.clone()); } + if !entry.found { + let edit_button = ui_kit::button(ui, tr("ui.ai_edit"), ui_kit::Button::Small); + crate::scenario::publish_handle(&format!("prefs:ai_edit:{}", entry.id), edit_button.rect.center()); + if edit_button.on_hover_text(tr("ui.ai_edit_tip")).clicked() { + edit = Some(entry.id.clone()); + } + } if !entry.found && ui_kit::button(ui, tr("ui.ai_remove"), ui_kit::Button::Ghost).clicked() { remove = Some(entry.id.clone()); } @@ -458,8 +515,11 @@ fn agents_section(app: &mut SolveApp, ui: &mut egui::Ui) { } }); ui_kit::settings_short_note(ui, &entry.detail()); - // Wide enough for an endpoint's "Request timeout" too, so both rows line up. - let label_w = (t.form_label_width + t.space_16).max(ui_kit::ContentWidth::label(ui, tr("ui.ai_request_timeout")) + t.space_16); + // Wide enough for an endpoint's "Request timeout" or "Reasoning effort" too, + // so all rows line up. + let label_w = (t.form_label_width + t.space_16) + .max(ui_kit::ContentWidth::label(ui, tr("ui.ai_request_timeout")) + t.space_16) + .max(ui_kit::ContentWidth::label(ui, tr("ui.ai_reasoning_effort")) + t.space_16); ui_kit::settings_form(ui, label_w, |ui| { ui_kit::row(ui, tr("ui.ai_permissions"), |ui| { let current = app.preferences.agent_permissions.get(&entry.id).copied(); @@ -497,6 +557,14 @@ fn agents_section(app: &mut SolveApp, ui: &mut egui::Ui) { retimed = Some((entry.id.clone(), secs)); } }); + ui_kit::row(ui, tr("ui.ai_reasoning_effort"), |ui| { + let mut effort = c.reasoning_effort.clone(); + if effort_field(ui, &mut effort) { + let effort = agents::reasoning_effort(&effort); + c.reasoning_effort = effort.clone(); + reeffort = Some((entry.id.clone(), effort)); + } + }); }); } if app.preferences.agent_permissions.get(&entry.id) == Some(&Mode::All) { @@ -523,6 +591,9 @@ fn agents_section(app: &mut SolveApp, ui: &mut egui::Ui) { let started = start_test(app, &e, key); app.agent_chat.settings.tests.insert(e.id.clone(), started); } + if let Some(id) = edit { + open_edit(app, &id); + } // The running conversation uses a new timeout from its next request on. if let Some((id, secs)) = retimed && app.agent_chat.running_id == id @@ -530,6 +601,13 @@ fn agents_section(app: &mut SolveApp, ui: &mut egui::Ui) { { b.set_request_timeout(secs); } + // A new reasoning effort likewise takes effect from the next request on. + if let Some((id, effort)) = reeffort + && app.agent_chat.running_id == id + && let Some(b) = app.agent_chat.backend.as_mut() + { + b.set_reasoning_effort(&effort); + } if let Some(id) = remove { app.agent_chat.login_results.remove(&id); app.agent_chat.settings.tests.remove(&id); @@ -565,32 +643,37 @@ pub fn dialog(app: &mut SolveApp, ctx: &egui::Context) { let mut open = true; let mut add = false; let mut test = false; - let (closed, action) = ui_kit::Window::new(egui::Id::new("sc_ai_add"), "settings", tr("ui.ai_add_agent_title")) + // Editing keeps the agent's kind: the kind switch is for adding only. + let editing = app.agent_chat.settings.add.as_ref().is_some_and(|s| s.editing.is_some()); + let (title, ok) = if editing { ("ui.ai_edit_agent_title", "ui.ai_save") } else { ("ui.ai_add_agent_title", "ui.ai_add") }; + let (closed, action) = ui_kit::Window::new(egui::Id::new("sc_ai_add"), "settings", tr(title)) .cards(2.0) .fields() - .actions(tr("ui.ai_add"), "Cancel", None) + .actions(tr(ok), "Cancel", None) .show(ctx, true, |ui| { let Some(s) = app.agent_chat.settings.add.as_mut() else { return }; if let Some(test) = s.test.as_mut() { let _ = test.poll(); } let kinds = [(AddKind::Installed, "ui.ai_add_installed"), (AddKind::Custom, "ui.ai_add_custom"), (AddKind::Api, "ui.ai_add_endpoint")]; - ui_kit::row(ui, tr("ui.ai_kind"), |ui| { - let mut selected = kinds.iter().position(|(k, _)| *k == s.kind).unwrap_or(0); - let labels = kinds.map(|(_, key)| tr(key)); - if ui_kit::segmented(ui, &mut selected, &labels, ui_kit::Segments { compact: true, ..Default::default() }) - && let Some((kind, _)) = kinds.get(selected) - { - s.kind = *kind; - s.error = None; - s.test = None; - } - for (_, key) in kinds { - if let Some(at) = crate::scenario::handle_at(&format!("dialog:{}:{}", tr("ui.ai_kind"), tr(key))) { - crate::scenario::publish_handle(&format!("ai_add:{key}"), at); + if !editing { + ui_kit::row(ui, tr("ui.ai_kind"), |ui| { + let mut selected = kinds.iter().position(|(k, _)| *k == s.kind).unwrap_or(0); + let labels = kinds.map(|(_, key)| tr(key)); + if ui_kit::segmented(ui, &mut selected, &labels, ui_kit::Segments { compact: true, ..Default::default() }) + && let Some((kind, _)) = kinds.get(selected) + { + s.kind = *kind; + s.error = None; + s.test = None; } - } - }); + for (_, key) in kinds { + if let Some(at) = crate::scenario::handle_at(&format!("dialog:{}:{}", tr("ui.ai_kind"), tr(key))) { + crate::scenario::publish_handle(&format!("ai_add:{key}"), at); + } + } + }); + } match s.kind { AddKind::Installed => { ui_kit::row(ui, tr("ui.agent_chat_pick"), |ui| { @@ -673,9 +756,13 @@ pub fn dialog(app: &mut SolveApp, ctx: &egui::Context) { ui_kit::row(ui, tr("ui.ai_request_timeout"), |ui| { timeout_field(ui, &mut s.request_timeout_s, "ai_add:request_timeout"); }); + ui_kit::row(ui, tr("ui.ai_reasoning_effort"), |ui| { + effort_field(ui, &mut s.reasoning_effort); + }); ui_kit::row(ui, tr("ui.ai_key"), |ui| { ui.add_enabled_ui(has_keychain, |ui| { - let r = ui_kit::password_field(ui, egui::Id::new("sc_ai_add_key"), &mut s.key, tr("ui.ai_key_hint")); + let hint = tr(if s.saved_key { "ui.ai_key_keep_hint" } else { "ui.ai_key_hint" }); + let r = ui_kit::password_field(ui, egui::Id::new("sc_ai_add_key"), &mut s.key, hint); crate::scenario::publish_target("ai_add:key", r.rect); }); }); @@ -711,8 +798,16 @@ pub fn dialog(app: &mut SolveApp, ctx: &egui::Context) { model: s.model.trim().to_string(), key: false, request_timeout_s: s.request_timeout_s, + reasoning_effort: agents::reasoning_effort(&s.reasoning_effort), + problem: None, + install: "", }; - let key = Some(s.key.trim().to_string()).filter(|k| !k.is_empty()); + // Editing with the key field left empty tests with the stored key, which is only ever + // sent where a saved key may go (HTTPS or this computer). + let key = Some(s.key.trim().to_string()).filter(|k| !k.is_empty()).or_else(|| { + let id = s.editing.as_deref().filter(|_| s.saved_key && url.as_deref().is_ok_and(agents::key_safe_for))?; + app.services.secrets.as_ref().and_then(|store| store.get(id).ok().flatten()) + }); let result = match url { Ok(_) => start_test(app, &e, key), Err(err) => Test::Done(Err(TestError::Other(err))), @@ -740,9 +835,14 @@ pub fn dialog(app: &mut SolveApp, ctx: &egui::Context) { } } -/// Add what the dialog holds; its key goes to the keychain. +/// Add what the dialog holds, or save it over the agent being edited; its key goes to the keychain. fn add_agent(app: &mut SolveApp) -> Result<(), String> { let Some(s) = app.agent_chat.settings.add.as_ref() else { return Ok(()) }; + // The agent being edited must still be there (it may have been removed meanwhile). + let editing = match &s.editing { + Some(id) => Some(app.preferences.agents.iter().position(|c| &c.id == id).ok_or_else(|| tr("ui.ai_edit_gone").to_string())?), + None => None, + }; let name = s.name.trim().chars().take(80).collect::(); let mut c = AgentConfig { name, ..Default::default() }; let mut key: Option = None; @@ -762,16 +862,23 @@ fn add_agent(app: &mut SolveApp) -> Result<(), String> { c.base_url = agents::normalize_url(&s.base_url)?; c.model = s.model.trim().to_string(); c.request_timeout_s = s.request_timeout_s.clamp(agents::MIN_REQUEST_TIMEOUT_S, agents::MAX_REQUEST_TIMEOUT_S); + c.reasoning_effort = agents::reasoning_effort(&s.reasoning_effort); if c.model.is_empty() { return Err(tr("ui.ai_need_model").to_string()); } key = Some(s.key.trim().to_string()).filter(|k| !k.is_empty()); - if key.is_some() && !agents::key_safe_for(&c.base_url) { + // Editing with the key field left empty keeps the stored key. + let kept = key.is_none() && editing.is_some() && s.saved_key; + if (key.is_some() || kept) && !agents::key_safe_for(&c.base_url) { return Err(tr("ui.ai_key_needs_https").to_string()); } + c.key = kept; c.id = agents::new_id(&app.preferences, c.kind.id()); } } + if let Some(id) = &s.editing { + c.id = id.clone(); + } if c.name.is_empty() { c.name = c.id.clone(); } @@ -780,12 +887,43 @@ fn add_agent(app: &mut SolveApp) -> Result<(), String> { store.set(&c.id, &k).map_err(|e| trf("ui.ai_keychain_error", &[("error", &e)]))?; c.key = true; } - app.preferences.agents.push(c); + let Some(at) = editing else { + app.preferences.agents.push(c); + return Ok(()); + }; + match app.preferences.agents.get_mut(at) { + None => return Err(tr("ui.ai_edit_gone").to_string()), + Some(old) => { + // An endpoint edited into a program (or one whose key was dropped) leaves no key behind. + if old.key + && !c.key + && let Some(store) = &app.services.secrets + { + let _ = store.delete(&c.id); + } + *old = c; + let id = old.id.clone(); + let secs = old.request_timeout_s; + let effort = old.reasoning_effort.clone(); + // A new command, endpoint or key has not been tested or signed in yet. + app.agent_chat.settings.tests.remove(&id); + app.agent_chat.login_results.remove(&id); + // The running conversation takes a new timeout from its next request on; the rest + // applies the next time the agent connects. + if app.agent_chat.running_id == id + && let Some(b) = app.agent_chat.backend.as_mut() + { + b.set_request_timeout(secs); + b.set_reasoning_effort(&effort); + } + } + } Ok(()) } #[cfg(all(test, not(target_arch = "wasm32")))] mod card_tests { + use super::super::backend::Secrets; use super::*; use crate::scenario::{self, Harness}; use serde_json::json; @@ -885,4 +1023,160 @@ mod card_tests { assert_eq!(saved.base_url, "https://api.anthropic.com/v1"); assert_eq!(saved.model, "claude-opus-5-5"); } + + /// A keychain in memory, for tests. + #[derive(Default)] + struct MemoryKeys(std::sync::Mutex>); + + impl Secrets for MemoryKeys { + fn get(&self, id: &str) -> Result, String> { + Ok(self.0.lock().map_err(|e| e.to_string())?.get(id).cloned()) + } + fn set(&self, id: &str, secret: &str) -> Result<(), String> { + self.0.lock().map_err(|e| e.to_string())?.insert(id.into(), secret.into()); + Ok(()) + } + fn delete(&self, id: &str) -> Result<(), String> { + self.0.lock().map_err(|e| e.to_string())?.remove(id); + Ok(()) + } + } + + /// Edit on an added agent reopens the dialog filled in; Save changes it in place, keeping its + /// id, its permission override and its always-allowed tools (#9619). + #[test] + fn edit_reopens_an_added_agent_and_saves_it_in_place() { + let mut h = Harness::new(); + h.call("ui.resize", json!({"width": 1600.0, "height": 1000.0})); + h.app.home.open = false; + h.app.preferences.ai_features = true; + h.app.agent_chat.detection = Some(Default::default()); + h.app.preferences.agents.push(AgentConfig { + id: "custom-1".into(), + name: "Mine".into(), + command: "my-agent --acp".into(), + ..Default::default() + }); + h.app.preferences.agent_permissions.insert("custom-1".into(), Mode::Edits); + h.app.preferences.agent_always_allow.insert("custom-1".into(), vec!["read".into()]); + h.call("ui.prefs", json!({"open": "AI"})); + h.frames(4); + let edit = scenario::handle_at("prefs:ai_edit:custom-1").expect("an added agent has Edit"); + h.call("ui.click", json!({"x": edit.x, "y": edit.y})); + h.frames(4); + { + let s = h.app.agent_chat.settings.add.as_ref().expect("Edit opened the dialog"); + assert_eq!(s.editing.as_deref(), Some("custom-1")); + assert_eq!(s.kind, AddKind::Custom); + assert_eq!((s.name.as_str(), s.command.as_str()), ("Mine", "my-agent --acp")); + } + assert!(scenario::handle_at("ai_add:ui.ai_add_custom").is_none(), "editing keeps the agent's kind"); + let s = h.app.agent_chat.settings.add.as_mut().unwrap(); + s.name = "Mine, verbose".into(); + s.command = "my-agent --acp --verbose".into(); + h.frames(3); + h.step(&json!({"click":{"handle":"ai_add:add"}})).unwrap(); + assert!(h.app.agent_chat.settings.add.is_none(), "Save closed the dialog"); + assert_eq!(h.app.preferences.agents.len(), 1, "saved in place, not added"); + let saved = &h.app.preferences.agents[0]; + assert_eq!((saved.id.as_str(), saved.name.as_str(), saved.command.as_str()), ("custom-1", "Mine, verbose", "my-agent --acp --verbose")); + assert_eq!(h.app.preferences.agent_permissions.get("custom-1"), Some(&Mode::Edits)); + assert!(h.app.preferences.agent_always_allow.contains_key("custom-1")); + // An empty command is refused as when adding. + assert!(open_edit(&mut h.app, "custom-1")); + h.app.agent_chat.settings.add.as_mut().unwrap().command = " ".into(); + assert_eq!(add_agent(&mut h.app), Err(tr("ui.ai_need_command").to_string())); + assert_eq!(h.app.preferences.agents[0].command, "my-agent --acp --verbose"); + } + + /// Editing an endpoint never shows its stored key: an empty key field keeps it, a new one + /// replaces it, and a kept key still only goes to HTTPS or this computer (#9619). + #[test] + fn edit_keeps_or_replaces_a_stored_key_without_showing_it() { + let mut h = Harness::new(); + let keys = std::sync::Arc::new(MemoryKeys::default()); + h.app.services.secrets = Some(keys.clone()); + keys.set("api-1", "test-key-1").unwrap(); + h.app.preferences.agents.push(AgentConfig { + id: "api-1".into(), + name: "OpenAI".into(), + kind: Kind::Api, + base_url: "https://api.openai.com/v1".into(), + model: "model-a".into(), + key: true, + request_timeout_s: 600, + reasoning_effort: "high".into(), + ..Default::default() + }); + assert!(open_edit(&mut h.app, "api-1")); + { + let s = h.app.agent_chat.settings.add.as_ref().unwrap(); + assert!(s.key.is_empty(), "the stored key is never read back into the dialog"); + assert!(s.saved_key); + assert_eq!((s.kind, s.protocol), (AddKind::Api, Protocol::OpenAi)); + assert_eq!(agents::presets(Protocol::OpenAi).get(s.preset).map(|p| p.name), Some("OpenAI")); + assert_eq!((s.base_url.as_str(), s.model.as_str(), s.request_timeout_s), ("https://api.openai.com/v1", "model-a", 600)); + assert_eq!(s.reasoning_effort, "high", "the chosen effort comes back into the dialog"); + } + h.app.agent_chat.settings.add.as_mut().unwrap().model = "model-b".into(); + add_agent(&mut h.app).unwrap(); + let saved = &h.app.preferences.agents[0]; + assert_eq!((saved.id.as_str(), saved.model.as_str(), saved.key, saved.request_timeout_s), ("api-1", "model-b", true, 600)); + assert_eq!(saved.reasoning_effort, "high", "the effort survives an edit"); + assert_eq!(keys.get("api-1").unwrap().as_deref(), Some("test-key-1"), "an empty field keeps the key"); + // A kept key does not go to a plain-HTTP address elsewhere. + assert!(open_edit(&mut h.app, "api-1")); + h.app.agent_chat.settings.add.as_mut().unwrap().base_url = "http://example.com/v1".into(); + assert_eq!(add_agent(&mut h.app), Err(tr("ui.ai_key_needs_https").to_string())); + assert_eq!(h.app.preferences.agents[0].base_url, "https://api.openai.com/v1"); + // A new key replaces the stored one. + assert!(open_edit(&mut h.app, "api-1")); + h.app.agent_chat.settings.add.as_mut().unwrap().key = "test-key-2".into(); + add_agent(&mut h.app).unwrap(); + assert_eq!(keys.get("api-1").unwrap().as_deref(), Some("test-key-2")); + assert_eq!(h.app.preferences.agents.len(), 1); + // Custom URLs and the Ollama preset come back as they were chosen. + h.app.preferences.agents.push(AgentConfig { + id: "api-2".into(), + kind: Kind::Api, + base_url: "http://192.168.1.20:11434/v1".into(), + model: "m".into(), + ollama: true, + ..Default::default() + }); + h.app.preferences.agents.push(AgentConfig { + id: "anthropic-1".into(), + kind: Kind::Anthropic, + base_url: "https://example.com/anthropic/v1".into(), + model: "m".into(), + ..Default::default() + }); + assert!(open_edit(&mut h.app, "api-2")); + assert_eq!(h.app.agent_chat.settings.add.as_ref().map(|s| (s.preset, s.saved_key)), Some((0, false))); + add_agent(&mut h.app).unwrap(); + assert!(h.app.preferences.agents.iter().any(|c| c.id == "api-2" && c.ollama && c.base_url == "http://192.168.1.20:11434/v1")); + assert!(open_edit(&mut h.app, "anthropic-1")); + let s = h.app.agent_chat.settings.add.as_ref().unwrap(); + assert_eq!((s.protocol, s.preset), (Protocol::Anthropic, agents::ANTHROPIC_PRESETS.len()), "a custom URL"); + add_agent(&mut h.app).unwrap(); + assert!(h.app.preferences.agents.iter().any(|c| c.id == "anthropic-1" && c.kind == Kind::Anthropic)); + assert_eq!(h.app.preferences.agents.len(), 3); + } + + /// Only added agents can be edited, and an agent removed while its dialog is open is not + /// brought back by Save; the control channel opens the same dialog (#9619). + #[test] + fn edit_only_for_added_agents_and_not_after_removal() { + let mut h = Harness::new(); + assert!(!open_edit(&mut h.app, "claude"), "a found agent has nothing of yours to edit"); + assert!(h.app.agent_chat.settings.add.is_none()); + h.app.preferences.agents.push(AgentConfig { id: "custom-1".into(), command: "a --acp".into(), ..Default::default() }); + let err = control(&mut h.app, &h.ctx, &json!({"edit_dialog": "nope"})).unwrap_err(); + assert!(err.contains("nope"), "{err}"); + control(&mut h.app, &h.ctx, &json!({"edit_dialog": "custom-1"})).unwrap(); + assert_eq!(h.app.agent_chat.settings.add.as_ref().and_then(|s| s.editing.as_deref()), Some("custom-1")); + h.app.preferences.agents.clear(); + assert_eq!(add_agent(&mut h.app), Err(tr("ui.ai_edit_gone").to_string())); + assert!(h.app.preferences.agents.is_empty()); + } } diff --git a/crates/ui-egui/src/agent_chat/tests.rs b/crates/ui-egui/src/agent_chat/tests.rs index de6a329915..9277669437 100644 --- a/crates/ui-egui/src/agent_chat/tests.rs +++ b/crates/ui-egui/src/agent_chat/tests.rs @@ -17,6 +17,8 @@ fn app_with_discovered_login() -> SolveApp { argv: vec!["qwen".into(), "--acp".into()], version: None, login: discover::Login::LooksLoggedIn, + problem: None, + install: "npm install -g @qwen-code/qwen-code", }], ..Default::default() }); @@ -98,6 +100,38 @@ fn agent_login_refresh_returns_to_unverified_discovery() { assert!(!app.agent_chat.settings.tests.contains_key("qwen")); } +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn a_sign_in_proven_in_one_tab_holds_in_every_tab() { + // #9293: discovery found no login file before the user signed in from the panel. The chat's + // proof of the sign-in belongs to the window, so a new tab doesn't ask for it again. + let mut app = app_with_discovered_login(); + if let Some(d) = app.agent_chat.detection.as_mut() { + d.found[0].login = discover::Login::NotLoggedIn; + } + assert_eq!(assistant_mark(&app), AssistantMark::NeedsAuth); + app.agent_chat.on_event(Event::Ready { agent: "Qwen Code".into(), images: false }); + assert_eq!(agents::current(&app).unwrap().login, "signed_in"); + + crate::documents::open_tab(&mut app, solidcraft_engine::Session::default()).unwrap(); + assert_eq!(app.agent_chat.state(), State::Off, "a new tab starts its own conversation"); + assert_eq!(agents::current(&app).unwrap().login, "signed_in"); + assert_eq!(assistant_mark(&app), AssistantMark::Ready, "no sign-in mark in the new tab"); + + // Evidence from the new tab is the newest, also back in the first one. + app.agent_chat.running_id = "qwen".into(); + app.agent_chat.on_event(Event::AuthRequired { methods: vec![] }); + crate::documents::switch(&mut app, 0); + assert_eq!(agents::current(&app).unwrap().login, "signed_out"); + app.agent_chat.on_event(Event::Ready { agent: "Qwen Code".into(), images: false }); + + // A replacement design starts a fresh conversation, not a fresh sign-in. + crate::documents::reset_chat(&mut app); + assert_eq!(agents::current(&app).unwrap().login, "signed_in"); + crate::documents::close(&mut app, 0, true); + assert_eq!(agents::current(&app).unwrap().login, "signed_in", "closing a tab keeps the window's evidence"); +} + #[test] fn streamed_text_joins_and_tool_updates_change_their_call() { let mut c = chat(); @@ -678,6 +712,203 @@ fn a_late_sign_in_retry_or_permission_cannot_restart_a_departed_turn() { assert!(app.agent_chat.turn.is_none() && app.agent_chat.queued.is_none()); } +/// Exercise the chat endpoint's gate and the real dispatch paths, including the animated +/// pointer and asynchronous file loads. The scripted backend never needs a model account. +fn chat_tool(app: &mut SolveApp, tx: &Sender, ctx: &egui::Context, method: &str, params: Value) -> Value { + let reply = ask(tx, method, params); + pump_tools(app); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + loop { + app.drain_control(ctx); + app.now += 1.0; + crate::agent_cursor::step(app, ctx); + crate::file_load::tick(app, ctx); + app.poll_control_jobs(ctx); + match reply.try_recv() { + Ok(response) => return response, + Err(std::sync::mpsc::TryRecvError::Disconnected) => panic!("the chat's tool reply was dropped"), + Err(std::sync::mpsc::TryRecvError::Empty) => {} + } + assert!(std::time::Instant::now() < deadline, "the chat's tool did not finish"); + std::thread::yield_now(); + } +} + +#[test] +fn an_agents_new_design_keeps_its_conversation_and_continues() { + // #7526: the old reset disconnected the backend inside its own file.new request. + for (method, params, animate) in [ + ("engine.execute", json!({"command": "file.new"}), false), + ("engine.execute", json!({"command": "FILE.NEW"}), false), + ("engine.execute", json!({"command": "file.new"}), true), + ("engine.batch", json!({"commands": [{"command": "file.new"}, boxed(0.0)]}), false), + ("engine.batch", json!({"commands": [{"command": "file.new"}, boxed(0.0)]}), true), + ] { + let (mut app, script, ctx) = app_with_script(); + let (tx, rx) = channel(); + app.agent_chat.tools_rx = Some(rx); + app.preferences.ai_permissions = agents::Mode::All; + app.run("solid.box", boxed(0.0)["params"].clone()).unwrap(); + send(&mut app, &ctx, "create another design".into()); + let active = app.agent_chat.turn.as_ref().unwrap().active.clone(); + app.ui.agent_cursor.show = animate; + crate::scenario::publish_handle("toolbar:file.new", egui::pos2(80.0, 100.0)); + crate::scenario::publish_handle("toolbar:solid.box", egui::pos2(100.0, 100.0)); + let response = chat_tool(&mut app, &tx, &ctx, method, params); + assert_eq!(response["ok"], true, "{response}"); + assert!(app.agent_chat.backend.is_some(), "the agent must receive its own replacement's reply"); + assert!(active.load(std::sync::atomic::Ordering::Relaxed)); + assert!(app.agent_chat.items.iter().any(|i| matches!(i, Item::User { text, .. } if text == "create another design"))); + assert!(!script.calls().iter().any(|call| matches!(call.as_str(), "cancel" | "new_session"))); + let response = chat_tool(&mut app, &tx, &ctx, "engine.execute", boxed(30.0)); + assert_eq!(response["ok"], true, "the same agent continues: {response}"); + assert_eq!(app.agent_chat.turn.as_ref().unwrap().changes, 2, "a replacement does not reset the turn's change budget"); + let count = if method == "engine.batch" { 2 } else { 1 }; + assert_eq!(app.session.doc.features.len(), count); + ended(&script, ""); + poll(&mut app, &ctx); + assert_eq!(app.session.undo.len(), 1, "only the new design's edits form this turn's undo step"); + app.run("edit.undo", json!({})).unwrap(); + assert!(app.session.doc.features.is_empty(), "undo must not resurrect the previous design"); + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn an_agents_opened_design_keeps_its_conversation_and_undo_ownership() { + for blank in [true, false] { + let dir = std::env::temp_dir().join(format!("solidcraft-chat-open-{}-{blank}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("next.solidcraft"); + let mut next = solidcraft_engine::Session::default(); + next.execute("solid.box", &boxed(100.0)["params"]).unwrap(); + next.execute("file.save_as", &json!({"path": path})).unwrap(); + let (mut app, script, ctx) = app_with_script(); + let (tx, rx) = channel(); + app.agent_chat.tools_rx = Some(rx); + app.preferences.ai_permissions = agents::Mode::All; + if !blank { + app.run("solid.box", boxed(0.0)["params"].clone()).unwrap(); + app.run("file.save_as", json!({"path": dir.join("first.solidcraft")})).unwrap(); + } + send(&mut app, &ctx, "open another design and add a box".into()); + let active = app.agent_chat.turn.as_ref().unwrap().active.clone(); + if !blank { + assert_eq!(chat_tool(&mut app, &tx, &ctx, "engine.execute", boxed(20.0))["ok"], true); + assert_eq!(chat_tool(&mut app, &tx, &ctx, "engine.execute", boxed(40.0))["ok"], true); + } + let response = + chat_tool(&mut app, &tx, &ctx, "engine.execute", json!({"command": "doc.open", "params": {"path": path}, "allow_paths": [dir]})); + assert_eq!(response["ok"], true, "{response}"); + assert!(app.agent_chat.backend.is_some(), "opening the file must carry the initiating chat"); + assert!(active.load(std::sync::atomic::Ordering::Relaxed)); + assert_eq!(app.docs.count(), if blank { 1 } else { 2 }); + if !blank { + let response = chat_tool( + &mut app, + &tx, + &ctx, + "engine.execute", + json!({ + "command": "doc.open", "params": {"path": dir.join("first.solidcraft")}, "allow_paths": [dir] + }), + ); + assert_eq!(response["ok"], false, "another tab must keep its own conversation: {response}"); + assert!(response["error"].as_str().unwrap().contains("already open in another tab")); + assert_eq!(app.docs.active, 1); + } + assert_eq!(chat_tool(&mut app, &tx, &ctx, "engine.execute", boxed(130.0))["ok"], true); + assert_eq!(chat_tool(&mut app, &tx, &ctx, "engine.execute", boxed(150.0))["ok"], true); + assert_eq!(app.session.doc.features.len(), 3); + ended(&script, ""); + poll(&mut app, &ctx); + assert!(!script.calls().iter().any(|call| matches!(call.as_str(), "cancel" | "new_session"))); + assert_eq!(app.session.undo.len(), 1); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.doc.features.len(), 1, "the opened design's existing body survives undo"); + if !blank { + crate::documents::switch(&mut app, 0); + assert_eq!(app.session.doc.features.len(), 3); + assert_eq!(app.session.undo.len(), 2, "the first design keeps its own grouped agent edits"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.doc.features.len(), 1); + assert!(app.agent_chat.backend.is_none(), "the endpoint moved with the conversation"); + crate::documents::switch(&mut app, 1); + assert!(app.agent_chat.backend.is_some()); + } + let _ = std::fs::remove_dir_all(&dir); + } +} + +#[test] +fn an_agents_failed_new_design_batch_restores_its_undo_boundary() { + let (mut app, script, ctx) = app_with_script(); + let (tx, rx) = channel(); + app.agent_chat.tools_rx = Some(rx); + app.preferences.ai_permissions = agents::Mode::All; + app.run("solid.box", boxed(0.0)["params"].clone()).unwrap(); + send(&mut app, &ctx, "try another design".into()); + assert_eq!(chat_tool(&mut app, &tx, &ctx, "engine.execute", boxed(20.0))["ok"], true); + let before = app.session.doc.clone(); + let response = chat_tool( + &mut app, + &tx, + &ctx, + "engine.batch", + json!({"commands": [ + {"command": "file.new"}, + {"command": "sketch.line", "params": {"points": [[0, 0], [10, 10]]}} + ]}), + ); + assert_eq!(response["result"]["completed"], 1, "the new design ran before the failure: {response}"); + assert_eq!(response["result"]["rolled_back"], true); + assert!(Arc::ptr_eq(&app.session.doc, &before)); + assert!(app.agent_chat.backend.is_some()); + assert_eq!(chat_tool(&mut app, &tx, &ctx, "engine.execute", boxed(40.0))["ok"], true); + ended(&script, ""); + poll(&mut app, &ctx); + assert_eq!(app.session.undo.len(), 2, "edits before and after the failed batch share one turn"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.doc.features.len(), 1); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn an_agents_cancelled_open_cannot_replace_the_design_later() { + let dir = std::env::temp_dir().join(format!("solidcraft-chat-cancel-open-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("next.solidcraft"); + solidcraft_engine::Session::default().execute("file.save_as", &json!({"path": path})).unwrap(); + let (mut app, script, ctx) = app_with_script(); + let (tx, rx) = channel(); + app.agent_chat.tools_rx = Some(rx); + app.preferences.ai_permissions = agents::Mode::All; + app.run("solid.box", boxed(0.0)["params"].clone()).unwrap(); + let original = app.session.doc.clone(); + send(&mut app, &ctx, "open another design".into()); + let (started_tx, started_rx) = channel(); + let (release_tx, release_rx) = channel(); + app.file_load.worker_gate = Some((started_tx, release_rx)); + let reply = ask(&tx, "engine.execute", json!({"command": "doc.open", "params": {"path": path}, "allow_paths": [dir]})); + pump_tools(&mut app); + app.drain_control(&ctx); + started_rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap(); + cancel(&mut app); + ended(&script, "cancelled"); + poll(&mut app, &ctx); + release_tx.send(()).unwrap(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while !app.file_load.idle() && std::time::Instant::now() < deadline { + crate::file_load::tick(&mut app, &ctx); + std::thread::yield_now(); + } + assert!(app.file_load.idle()); + assert_eq!(reply.try_recv().unwrap()["ok"], false, "a stopped turn cannot finish its pending open"); + assert!(Arc::ptr_eq(&app.session.doc, &original)); + assert_eq!(app.docs.count(), 1); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn programmatic_replacement_clears_the_conversation_and_invalidates_its_requests() { for command in ["file.new", "FILE.NEW"] { @@ -1114,6 +1345,56 @@ done let _ = std::fs::remove_dir_all(&dir); } +/// #9811: an agent that offers the standard `systemPrompt` field gets the note in it, on the first +/// session and on the one New chat opens. +#[cfg(unix)] +#[test] +fn an_acp_agent_offering_the_standard_field_gets_the_system_note_in_every_session() { + let dir = std::env::temp_dir().join(format!("sc-agent-chat-note-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let log = dir.join("received.log"); + let script = r#" +log="$1"; n=0 +while IFS= read -r line; do + printf '%s\n' "$line" >> "$log" + id=$(printf '%s' "$line" | sed -n 's/.*"id":\([0-9][0-9]*\).*/\1/p') + case "$line" in + *'"method":"initialize"'*) printf '{"jsonrpc":"2.0","id":%s,"result":{"agentCapabilities":{"sessionCapabilities":{"systemPrompt":{}}},"agentInfo":{"name":"fake","version":"1"}}}\n' "$id" ;; + *'"method":"session/new"'*) n=$((n+1)); printf '{"jsonrpc":"2.0","id":%s,"result":{"sessionId":"s%s"}}\n' "$id" "$n" ;; + esac +done +"#; + let argv = vec!["sh".to_string(), "-c".to_string(), script.to_string(), "fake-agent".to_string(), log.to_string_lossy().into_owned()]; + let start = backend::StartContext { cwd: dir.clone(), bridge: None, tools: None, wake: Box::new(|| {}) }; + let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); + app.agent_chat.backend = Some(Box::new(acp_client::AcpBackend::start(&argv, start).unwrap())); + app.agent_chat.state = Some(State::Starting); + let ctx = crate::fonts::test_context(); + let until = |app: &mut SolveApp, done: &dyn Fn(&SolveApp) -> bool| { + let t = std::time::Instant::now(); + while !done(app) { + assert!(t.elapsed() < std::time::Duration::from_secs(20), "timed out"); + poll(app, &ctx); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + }; + until(&mut app, &|a| a.agent_chat.state() == State::Ready); + new_chat(&mut app); + until(&mut app, &|a| a.agent_chat.state() == State::Ready); + let sessions: Vec = std::fs::read_to_string(&log) + .unwrap() + .lines() + .filter_map(|l| serde_json::from_str::(l).ok()) + .filter(|v| v["method"] == "session/new") + .collect(); + assert_eq!(sessions.len(), 2); + for session in &sessions { + assert_eq!(session.pointer("/params/systemPrompt/content/0/text").and_then(Value::as_str), Some(acp::SYSTEM_NOTE)); + } + disconnect(&mut app); + let _ = std::fs::remove_dir_all(&dir); +} + fn models_reply(current: &str) -> Event { let m = |id: &str| acp::ModelInfo { id: id.into(), name: id.to_uppercase(), description: String::new() }; Event::Models { current: current.into(), available: vec![m("fast"), m("smart")] } @@ -1662,6 +1943,61 @@ done let _ = std::fs::remove_dir_all(&dir); } +/// An agent that lists `models` but does not serve the unstable `session/set_model` answers +/// "method not found": the choice stays and no error shows (#9970). Other errors still show. +#[cfg(unix)] +#[test] +fn set_model_method_not_found_shows_no_error() { + let dir = std::env::temp_dir().join(format!("sc-agent-chat-model-unserved-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let log = dir.join("received.log"); + let script = r#" +log="$1"; n=0 +while IFS= read -r line; do + printf '%s\n' "$line" >> "$log" + id=$(printf '%s' "$line" | sed -n 's/.*"id":\([0-9][0-9]*\).*/\1/p') + case "$line" in + *'"method":"initialize"'*) printf '{"jsonrpc":"2.0","id":%s,"result":{"agentInfo":{"name":"fake","version":"1"}}}\n' "$id" ;; + *'"method":"session/new"'*) printf '{"jsonrpc":"2.0","id":%s,"result":{"sessionId":"s1","models":{"currentModelId":"fast","availableModels":[{"modelId":"fast","name":"Fast"},{"modelId":"smart","name":"Smart"}]}}}\n' "$id" ;; + *'"method":"session/set_model"'*) + n=$((n+1)) + if [ "$n" = 1 ]; then printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-32601,"message":"\\"Method not found\\": session/set_model"}}\n' "$id" + else printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-32001,"message":"model unavailable"}}\n' "$id"; fi ;; + esac +done +"#; + let argv = vec!["sh".to_string(), "-c".to_string(), script.to_string(), "fake-agent".to_string(), log.to_string_lossy().into_owned()]; + let start = backend::StartContext { cwd: dir.clone(), bridge: None, tools: None, wake: Box::new(|| {}) }; + let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); + app.agent_chat.backend = Some(Box::new(acp_client::AcpBackend::start(&argv, start).unwrap())); + app.agent_chat.state = Some(State::Starting); + app.agent_chat.running_id = "fake".into(); + let ctx = crate::fonts::test_context(); + let errors = |app: &SolveApp| -> Vec { + app.agent_chat.items.iter().filter_map(|i| if let Item::Notice { text, error: true } = i { Some(text.clone()) } else { None }).collect() + }; + let t = std::time::Instant::now(); + while !(app.agent_chat.state() == State::Ready && models_shown(&app)) { + poll(&mut app, &ctx); + assert!(t.elapsed() < std::time::Duration::from_secs(20), "agent not ready: {}", status(&app)); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + set_model(&mut app, "smart"); + set_model(&mut app, "fast"); + // Replies come in order: once the second one's error shows, the first reply was handled too. + let t = std::time::Instant::now(); + while !errors(&app).iter().any(|e| e == "model unavailable") { + poll(&mut app, &ctx); + assert!(t.elapsed() < std::time::Duration::from_secs(20), "no error for the second choice: {:?}", errors(&app)); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert_eq!(errors(&app), ["model unavailable"], "method not found is no error to show"); + assert!(app.agent_chat.state().running(), "the chat goes on"); + assert_eq!(std::fs::read_to_string(&log).unwrap().matches("session/set_model").count(), 2); + disconnect(&mut app); + let _ = std::fs::remove_dir_all(&dir); +} + // ---- Files attached to a message ---- /// A folder of files to attach: a picture and a text file. @@ -1900,6 +2236,35 @@ fn narrow_chat() -> crate::scenario::Harness { h } +/// #10172: before a connection, what the panel draws under the agent switcher (the Agent row +/// with Add agent and Refresh) keeps the panel's side margin, docked and floating: nothing starts +/// at the panel's edge or is cut off at the other one. +#[test] +fn the_agent_list_keeps_the_panels_side_margin() { + use crate::shell::LayoutMode; + for layout in [LayoutMode::Docked, LayoutMode::Floating] { + let mut h = narrow_chat(); + h.app.ui.layout = layout; + h.frames(3); + h.frame(); + let panel = crate::scenario::rect_of("agent_chat:panel").expect("the panel"); + let mut texts = Vec::new(); + for clipped in &h.shapes { + crate::test_support::each_shape(&clipped.shape, &mut |s| { + if let egui::Shape::Text(t) = s { + texts.push((t.galley.text().to_string(), egui::Rect::from_min_size(t.pos, t.galley.rect.size()))); + } + }); + } + let at = |label: &str| texts.iter().find(|(text, r)| text == label && panel.contains(r.center())).map(|(_, r)| *r); + let margin = crate::theme::Tokens::get().space_12 - 0.5; + let agent = at(tr("ui.agent_chat_pick")).expect("the Agent row"); + assert!(agent.left() >= panel.left() + margin, "{layout:?}: Agent at {agent:?} in {panel:?}"); + let refresh = at(tr("ui.agent_chat_refresh")).expect("Refresh"); + assert!(refresh.right() <= panel.right() - margin, "{layout:?}: Refresh at {refresh:?} in {panel:?}"); + } +} + /// Every chip and the text box sit inside the panel. fn assert_inside_the_panel(chips: usize) { use crate::scenario::rect_of; @@ -2435,6 +2800,34 @@ fn a_connection_that_never_gets_ready_says_connection_failed_and_offers_retry() assert_eq!(app.agent_chat.state(), State::Failed); assert!(app.preferences.agent_last.is_empty(), "only an agent that started is remembered"); + // A found agent that failed its version check (#9034): the picker says it doesn't start, and + // connecting says why and how to install it again, besides the system's own message. + let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); + let reason = "This version of opencode.exe is not compatible with the version of Windows you're running."; + app.agent_chat.detection = Some(discover::Detection { + found: vec![discover::Found { + id: "opencode", + name: "OpenCode", + argv: vec!["solidcraft-no-such-agent-9034".into(), "acp".into()], + version: None, + login: discover::Login::Unknown, + problem: Some(reason.into()), + install: "npm install -g opencode-ai", + }], + ..Default::default() + }); + let entry = agents::current(&app).unwrap(); + assert!(entry.detail().contains(reason), "{}", entry.detail()); + connect(&mut app, &ctx); + assert_eq!(app.agent_chat.state(), State::Failed); + let told = |needle: &str| app.agent_chat.items.iter().any(|i| matches!(i, Item::Notice { text, error: true } if text.contains(needle))); + assert!(told(reason) && told("npm install -g opencode-ai") && told("OpenCode"), "{:?}", app.agent_chat.items); + assert_eq!(status(&app)["agents"][0]["problem"], reason); + // One that answered has nothing to say. + let mut fine = entry.clone(); + fine.problem = None; + assert_eq!(fine.problem_hint(), None); + // No agent at all: the picker opens on what to install. let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); app.agent_chat.detection = Some(discover::Detection::default()); diff --git a/crates/ui-egui/src/agent_cursor.rs b/crates/ui-egui/src/agent_cursor.rs index 9236e3ada8..62b352b7ad 100644 --- a/crates/ui-egui/src/agent_cursor.rs +++ b/crates/ui-egui/src/agent_cursor.rs @@ -452,7 +452,8 @@ fn finish(app: &mut SolveApp, ctx: &egui::Context) { if follow && id == "sketch.create" { app.agent.saved_view = Some(app.cam); } - let (req, _rx) = crate::control::ControlRequest::new(job.method, params); + let (mut req, _rx) = crate::control::ControlRequest::new(job.method, params); + req.chat_turn = job.chat_turn; // What was computed ahead while the pointer glided is found rather than computed again here. let out = app.run_with_kept(job.ahead.map(|a| a.kept), |app| match crate::control::handle(app, ctx, &req) { crate::control::Outcome::Done(v) => v, diff --git a/crates/ui-egui/src/apply_off_thread_tests.rs b/crates/ui-egui/src/apply_off_thread_tests.rs index 4ac5dd4f03..b177fef687 100644 --- a/crates/ui-egui/src/apply_off_thread_tests.rs +++ b/crates/ui-egui/src/apply_off_thread_tests.rs @@ -103,6 +103,28 @@ fn cancel_while_computing_applies_nothing() { assert_eq!(h.app.session.doc.features.len(), features); } +#[test] +fn removing_the_edited_feature_discards_its_pending_result() { + let mut h = Harness::new(); + h.app.home.open = false; + h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"name":"Block"})).unwrap(); + h.call("ui.editFeature", json!({"feature":"Block"})); + h.frames(2); + let prepared = result(&h); + let finish = hold_result(&mut h, false); + let progress = h.app.applying.as_ref().unwrap().progress.clone(); + h.app.run("edit.undo", json!({})).unwrap(); + let document = h.app.session.doc.clone(); + let history = (h.app.session.undo.len(), h.app.session.redo.len()); + h.frames(2); + assert!(h.app.dialog.is_none() && h.app.applying.is_none() && h.app.editing_state.is_none()); + assert!(progress.is_cancelled()); + assert!(finish.send(prepared).is_err(), "the removed editor cannot adopt a late result"); + h.frames(2); + assert!(std::sync::Arc::ptr_eq(&document, &h.app.session.doc)); + assert_eq!((h.app.session.undo.len(), h.app.session.redo.len()), history); +} + #[test] fn a_ready_transaction_waits_one_frame_before_adoption() { let mut h = square_on_a_block(); @@ -323,7 +345,7 @@ fn cancelling_an_edit_restores_pinned_geometry_without_evaluating_an_evicted_suf let history = (h.app.session.undo.len(), h.app.session.redo.len()); let visibility = h.app.session.visibility.clone(); h.call("ui.editFeature", json!({"feature":target})); - crate::dialogs::sync_feature_edit_sketch(&mut h.app); + crate::dialogs::sync_feature_edit(&mut h.app); let sketch = h.app.session.feature_edit_sketch.expect("edited Extrude exposes its sketch"); assert!(solidcraft_engine::view::sketch_shown(&h.app.session, sketch)); let prepared = result(&h); diff --git a/crates/ui-egui/src/browser.rs b/crates/ui-egui/src/browser.rs index 0476a0d3fb..cad32c7be8 100644 --- a/crates/ui-egui/src/browser.rs +++ b/crates/ui-egui/src/browser.rs @@ -166,6 +166,9 @@ pub struct TreeState { pub section_hidden: bool, /// What waits for the Capture Position question: a command id, or [`SAVE`]. pub capture_prompt: Option, + /// The design, undo depth and visibility when the button last went down: a drag over the eyes + /// merges the steps it made into one (`eye_drag_frame`). + eye_press: Option<(Arc, usize, solidcraft_engine::Visibility)>, /// The last row clicked (for Shift ranges). anchor: Option, /// Row keys in drawing order this frame (for Shift ranges). @@ -435,6 +438,35 @@ pub fn toggle_folder(app: &mut SolveApp, key: &str) { let _ = app.run("browser.visibility", json!({ "tree_folders": [key], "visible": app.session.folder_hidden(key) })); } +/// A drag over the eyes is one undo step (#9680). When the button goes down the undo depth and the +/// hidden items are noted; when it comes up, the steps the drag made are merged into the first of +/// them. Runs every frame, whatever else is open, so a release is never missed. +pub(crate) fn eye_drag_frame(app: &mut SolveApp, ctx: &egui::Context) { + let (pressed, down) = ctx.input(|i| (i.pointer.primary_pressed(), i.pointer.primary_down())); + if pressed { + app.tree.eye_press = Some((app.session.doc.clone(), app.session.undo.len(), without_origin(app.session.visibility.clone()))); + } + if down { + return; + } + let dragged = ctx.data_mut(|d| d.remove_temp::(crate::ui_kit::eye_drag_id())).is_some(); + let Some((doc, depth, visibility)) = app.tree.eye_press.take().filter(|_| dragged) else { return }; + // shortcut: once the history is full (200 steps) its length stops growing, so the drag is left as + // separate steps, like the point-cloud and canvas drags; the visibility test keeps a trimmed front + // from being merged wrongly. + let undo = &mut app.session.undo; + if undo.len() > depth + 1 && undo.get(depth).is_some_and(|s| Arc::ptr_eq(&s.doc, &doc) && without_origin(s.visibility.clone()) == visibility) { + undo.truncate(depth + 1); + } +} + +/// The Origin's own eye is not an undo step (`set_origin_shown`), so the drag's steps do not hold it: +/// it is left out of the comparison. +fn without_origin(mut visibility: solidcraft_engine::Visibility) -> solidcraft_engine::Visibility { + visibility.origin_hidden = None; + visibility +} + /// A folder shortcut target lasts until another click, Escape, or a change of design/model. /// Visibility changes keep the document snapshot, so V can toggle the same folder again. pub(crate) fn update_folder_pick(app: &mut SolveApp, ctx: &egui::Context) { @@ -449,6 +481,14 @@ fn pick_folder(app: &mut SolveApp, key: &str) { app.tree.picked_folder = Some(FolderPick { key: key.into(), session: app.session.uid, doc: Arc::downgrade(&app.session.doc) }); } +/// The group whose row was clicked last (its key ends in `/g`), while that pick still holds +/// and the group exists: F2 renames it (#10005). +pub(crate) fn picked_group(app: &SolveApp) -> Option { + let pick = app.tree.picked_folder.as_ref().filter(|pick| pick.current(app))?; + let id = pick.key.rsplit_once("/g")?.1.parse().ok()?; + find_group(app, id).map(|g| g.id) +} + pub(crate) fn toggle_picked_folder(app: &mut SolveApp) -> bool { let Some(pick) = app.tree.picked_folder.as_ref().filter(|pick| pick.current(app)) else { return false }; let key = pick.key.clone(); @@ -725,10 +765,11 @@ impl Lit { // which does not rewrite the browser selection. let dialog_picks = app.dialog.as_ref().map(|d| d.items()).unwrap_or_default(); for s in app.session.selection.iter().chain(&dialog_picks) { - selected.insert(match s { - Sel::Body { name } | Sel::Face { body: name, .. } | Sel::Edge { body: name, .. } | Sel::Vertex { body: name, .. } => { - Thing::Body(name.clone()) - } + let thing = match s { + Sel::Body { name } => Thing::Body(name.clone()), + // A picked face, edge or vertex lights the feature it belongs to (see + // `browser::features`), not the whole body as if the body were selected. + Sel::Face { .. } | Sel::Edge { .. } | Sel::Vertex { .. } => continue, Sel::Feature { id } => Thing::Sketch(*id), Sel::Profile { sketch, .. } | Sel::ExternalSketchCurve { sketch, .. } | Sel::ExternalSketchPoint { sketch, .. } => { Thing::Sketch(*sketch) @@ -743,7 +784,8 @@ impl Lit { Sel::Axis { name } => Thing::Axis(name.clone()), Sel::ConstructionPoint { name } => Thing::Point(name.clone()), Sel::Occurrence { .. } => continue, - }); + }; + selected.insert(thing); } let hovered = match &app.viewport.hover { Some(Hit::Face { body, .. } | Hit::Edge { body, .. } | Hit::Vertex { body, .. }) => Some(Thing::Body(body.clone())), @@ -1712,6 +1754,8 @@ fn occurrence_rows_inner( } if !render { features::rows(app, ui, placement.component, path, depth + 1, acts); + // The joints that attach this placement, as the root's Joints folder lists them (#9130). + crate::dialogs_assembly::component_joints(app, ui, path, key, depth + 1); } if path.len() < 64 { for child in &placement.children { diff --git a/crates/ui-egui/src/browser/features.rs b/crates/ui-egui/src/browser/features.rs index 424fef29d3..145bf4316c 100644 --- a/crates/ui-egui/src/browser/features.rs +++ b/crates/ui-egui/src/browser/features.rs @@ -250,7 +250,11 @@ fn feature_row( let sketch = crate::timeline::TimelineKind::of(&feature.kind).profile_sketch(); // Behind the history marker or suppressed: not built, drawn faint as in the timeline. let dim = feature.suppressed || app.session.doc.marker.is_some_and(|marker| index >= marker); - let selected = app.session.selection.contains(&Sel::Feature { id }); + // A feature picked by its geometry (a face or edge of it chosen in the view) shows as + // selected in the tree, as NX marks the feature of a picked face; the session's selection + // stays the face or edge. + let by_geometry = !app.session.selection.contains(&Sel::Feature { id }) && crate::timeline::picked_feature(app) == Some(id); + let selected = app.session.selection.contains(&Sel::Feature { id }) || by_geometry; app.tree.remember(&format!("f:{id}")); let action = sketch.map(|_| ("sketch", "Edit Profile Sketch")); // A sketch row has the eye its Sketches folder row has. A feature's eye shows and hides the @@ -822,6 +826,58 @@ mod tests { assert!(labels.contains(&block_name), "a rolled-back feature stays listed: {labels:?}"); } + /// #10079: three extrusions of one visible sketch, the first renamed. A click just past the end + /// of a feature's name, or a press on the name that slips a few points before its release, + /// selects the feature; only the sketch button itself opens the sketch. + #[test] + fn clicking_just_past_a_feature_name_selects_it_without_opening_its_sketch() { + let mut h = Harness::new(); + let sketch = h.app.run("sketch.create", json!({"plane":"XY","name":"Sketch1"})).unwrap()["sketch"].as_u64().unwrap(); + for (p0, p1) in [([0, 0], [10, 10]), ([20, 0], [30, 10]), ([40, 0], [50, 10])] { + h.app.run("sketch.rectangle.two_point", json!({"p0":p0,"p1":p1})).unwrap(); + } + h.app.run("sketch.finish", json!({})).unwrap(); + let mut ids = Vec::new(); + for profile in 0..3 { + let made = h.app.run("solid.extrude", json!({"sketch":sketch,"profiles":[profile],"distance":5})).unwrap(); + ids.push(made["feature"].as_u64().unwrap()); + if profile == 0 { + h.app.run("timeline.rename", json!({"feature":ids[0],"name":"Lid"})).unwrap(); + } + } + // The sketch stays shown, as in the report. + crate::browser::set_sketch_visible(&mut h.app, sketch, true); + h.app.tree.expanded.insert("c0/features".into()); + h.settle(); + assert!(crate::browser::sketch_visible(&h.app, sketch)); + let press = |h: &mut Harness, at: Pos2, pressed: bool| { + h.frame_with(vec![egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }]); + }; + // A press at `from` released at `to`, then long enough that the next click is not a double. + let click = |h: &mut Harness, from: Pos2, to: Pos2| { + h.frame_with(vec![egui::Event::PointerMoved(from)]); + press(h, from, true); + h.frame_with(vec![egui::Event::PointerMoved(to)]); + press(h, to, false); + h.frames(30); + }; + for (round, id) in ids.iter().chain(&ids).enumerate() { + let name = h.app.session.doc.feature(*id).unwrap().name.clone(); + for (from, to) in [(1.0, 1.0), (5.0, 5.0), (-2.0, 4.0)] { + // Selected rows draw their name in semibold, wider: measure it each time. + let label = crate::scenario::rect_of(&format!("row_label:{name}")).unwrap(); + let at = |dx: f32| pos2(label.right() + dx, label.center().y); + click(&mut h, at(from), at(to)); + assert_eq!(h.app.session.active_sketch, None, "round {round}: {name} clicked {from}..{to} points past its name opened the sketch"); + assert!(h.app.dialog.is_none(), "round {round}: {name} opened a dialog"); + assert_eq!(h.app.session.selection, [Sel::Feature { id: *id }], "round {round}: {name} {from}..{to}"); + } + } + h.step(&json!({"click": {"handle": "action:Extrude1"}})).unwrap(); + h.frames(2); + assert_eq!(h.app.session.active_sketch, Some(sketch), "the sketch button still opens the sketch"); + } + fn settle_heavy(h: &mut Harness) { for _ in 0..500 { if h.app.deferred.is_none() && h.app.deferred_queue.is_empty() { diff --git a/crates/ui-egui/src/browser_card_tests.rs b/crates/ui-egui/src/browser_card_tests.rs index 7a778c9223..72e395e67d 100644 --- a/crates/ui-egui/src/browser_card_tests.rs +++ b/crates/ui-egui/src/browser_card_tests.rs @@ -43,6 +43,48 @@ fn clicked_browser_folders_toggle_visibility_with_v() { } } +/// #10005: F2 on a group picked in the Browser opens its Rename box, as for other rows, and the +/// typed name renames the group, at the top level and inside a component; a body selected +/// alongside does not take the rename from the clicked group. +#[test] +fn f2_on_a_clicked_browser_group_renames_the_group() { + for inside_component in [false, true] { + let (mut h, component) = if inside_component { + let (h, component, _) = crate::test_support::component_box(); + (h, component) + } else { + let mut h = Harness::new(); + h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Body1"})).unwrap(); + (h, 0) + }; + let body = h.app.session.model.state().bodies[0].name.clone(); + let group = h.app.run("browser.group", json!({"component":component,"folder":"bodies","items":[body],"name":"Group"})).unwrap()["group"] + .as_u64() + .unwrap(); + h.app.preferences.browser_bodies_folders = crate::prefs::BodiesFolders::Always; + h.settle(); + let world_body = h.app.session.world_state().bodies[0].name.clone(); + h.app.run("select.set", json!({"items":[Sel::Body { name: world_body.clone() }]})).unwrap(); + h.step(&json!({"click":{"handle":"row:Group (1)"}})).unwrap(); + h.step(&json!({"key":"F2"})).unwrap(); + h.frames(2); + let what = h.app.menu.rename.as_ref().map(|r| (r.what.clone(), r.text.clone())); + assert_eq!( + what, + Some((crate::context_menu::RenameWhat::Group(group), "Group".to_string())), + "component {inside_component}: F2 renames the group" + ); + h.step(&json!({"text":"Fasteners"})).unwrap(); + h.frames(1); + h.step(&json!({"key":"Enter"})).unwrap(); + h.frames(2); + assert!(h.app.menu.rename.is_none()); + let names: Vec<&str> = h.app.session.doc.browser_groups.iter().map(|g| g.name.as_str()).collect(); + assert_eq!(names, ["Fasteners"], "component {inside_component}: Enter renames the group; status {:?}", h.app.status); + assert!(h.app.session.world_state().body(&world_body).is_some(), "the body keeps its name"); + } +} + #[test] fn folder_visibility_target_clears_when_the_user_moves_on() { for action in ["body click", "selection command", "empty click", "escape", "design tab", "model edit", "tool"] { @@ -680,7 +722,7 @@ fn browser_occurrence_clicks_fill_align_components_input() { h.app.start("solid.align"); { let d = h.app.dialog.as_mut().unwrap(); - d.kind = crate::dialogs::Kind::Align { flip: false, components: true }; + d.kind = crate::dialogs::Kind::Align { flip: false, components: true, axes: [true; 3] }; d.inputs[0].label = "Components"; d.active = 0; } @@ -826,3 +868,117 @@ fn a_group_dragged_onto_another_group_nests_in_it_or_goes_before_it() { drag(&mut h, "row:Gamma (1)", "row:Beta (2)", -10.0); assert_eq!(h.app.session.doc.ordered_entries(0, "bodies", None), [BrowserEntry::Group(gamma), BrowserEntry::Group(beta)]); } + +/// Three bodies with their eyes in the Browser, the first one's eye hidden or shown per the test. +fn three_bodies(h: &mut Harness) { + for (name, x) in [("One", 0), ("Two", 20), ("Three", 40)] { + h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"corner":[x,0,0],"operation":"new_body","body_name":name})).unwrap(); + } + h.settle(); +} + +fn body_hidden(h: &Harness, name: &str) -> bool { + h.app.session.visibility.hidden_bodies.iter().any(|b| b == name) +} + +/// #9680: a press on an eye that drags over other eyes sets each eye it passes to the first eye's +/// new state, and the whole drag is one undo step. +#[test] +fn eye_drag_sets_every_eye_it_passes_in_one_undo_step() { + for eye_left in [false, true] { + let mut h = Harness::new(); + three_bodies(&mut h); + if eye_left { + h.call("ui.prefs", json!({"browser_eye_left": true})); + h.settle(); + } + let undo = h.app.session.undo.len(); + h.step(&json!({"drag":[{"handle":"eye:One"},{"handle":"eye:Three"}],"steps":40})).unwrap(); + h.settle(); + for name in ["One", "Two", "Three"] { + assert!(body_hidden(&h, name), "eye_left {eye_left}: {name} is hidden by the drag"); + } + assert_eq!(h.app.session.undo.len(), undo + 1, "eye_left {eye_left}: the drag is one undo step"); + h.app.run("edit.undo", json!({})).unwrap(); + for name in ["One", "Two", "Three"] { + assert!(!body_hidden(&h, name), "eye_left {eye_left}: one undo shows {name} again"); + } + } +} + +/// #9680: an eye already in the target state stays as it is: a drag from a shown eye over a hidden +/// one hides both, and undo brings back the hidden one as it was. +#[test] +fn eye_drag_leaves_an_eye_already_in_the_target_state_alone() { + let mut h = Harness::new(); + three_bodies(&mut h); + h.app.run("browser.visibility", json!({"items":["Two"],"visible":false})).unwrap(); + h.settle(); + h.step(&json!({"drag":[{"handle":"eye:One"},{"handle":"eye:Three"}],"steps":40})).unwrap(); + h.settle(); + for name in ["One", "Two", "Three"] { + assert!(body_hidden(&h, name), "{name} is hidden: the hidden eye is not flipped back"); + } + h.app.run("edit.undo", json!({})).unwrap(); + assert!(!body_hidden(&h, "One") && !body_hidden(&h, "Three"), "undo shows the two the drag hid"); + assert!(body_hidden(&h, "Two"), "undo keeps the eye hidden before the drag"); + h.app.run("edit.undo", json!({})).unwrap(); + assert!(!body_hidden(&h, "Two"), "the hide before the drag is its own undo step"); +} + +/// #9680: a plain click on an eye still toggles that one row, as one undo step. +#[test] +fn a_plain_eye_click_still_toggles_one_row_as_one_undo_step() { + let mut h = Harness::new(); + three_bodies(&mut h); + let undo = h.app.session.undo.len(); + h.step(&json!({"click":{"handle":"eye:Two"}})).unwrap(); + h.settle(); + assert!(body_hidden(&h, "Two") && !body_hidden(&h, "One") && !body_hidden(&h, "Three"), "only Two is hidden"); + assert_eq!(h.app.session.undo.len(), undo + 1, "the click is one undo step"); +} + +/// #9680: a drag over the eyes that rests near the rows' bottom edge scrolls them, and the eyes +/// the rows bring under the pointer take the drag's state too. +#[test] +fn eye_drag_near_the_rows_bottom_edge_scrolls_them() { + let mut h = Harness::new(); + h.call("ui.resize", json!({"width":1400,"height":900})); + for i in 0..35 { + h.app.run("sketch.create", json!({"plane":"XY", "name":format!("Sketch{i}")})).unwrap(); + h.app.run("sketch.finish", json!({})).unwrap(); + } + h.settle(); + assert!(handle_at("row:Sketch34").is_none(), "the rows overflow the card"); + let card = rect_of("browser:card").unwrap(); + let eye = handle_at("eye:Sketch0").unwrap(); + h.call("ui.drag", json!({"x0": eye.x, "y0": eye.y, "x1": eye.x, "y1": card.bottom() - 10.0, "steps": 20, "hold_end": 240})); + h.settle(); + assert!(handle_at("row:Sketch34").is_some(), "the drag scrolled the list down to its last row"); + assert!(h.app.session.visibility.hidden_sketches.len() > 10, "the eyes the drag reached are hidden"); +} + +/// #9680: a drag from the Origin's eye over the plane eyes is one undo step of its own, and the show +/// before it stays a step: one undo takes back only the drag. The Origin's eye is not an undo step (a +/// click on it is not either), so that one undo leaves it as the drag set it. +#[test] +fn eye_drag_from_the_origin_is_its_own_undo_step_and_leaves_an_earlier_show_alone() { + let mut h = Harness::new(); + h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"One"})).unwrap(); + h.app.run("browser.visibility", json!({"items":["One"],"visible":false})).unwrap(); + h.app.run("browser.visibility", json!({"items":["One"],"visible":true})).unwrap(); + h.app.run("browser.expand_all", json!({})).unwrap(); + h.settle(); + let undo = h.app.session.undo.len(); + h.step(&json!({"drag":[{"handle":"eye:Origin"},{"handle":"eye:YZ"}],"steps":40})).unwrap(); + h.settle(); + let origin_hidden = |h: &Harness| h.app.session.visibility.origin_hidden == Some(true); + assert!(origin_hidden(&h) && h.app.session.visibility.hidden_origin.iter().any(|k| k == "YZ"), "the drag hid the Origin and its planes"); + assert_eq!(h.app.session.undo.len(), undo + 1, "the drag is one undo step"); + h.app.run("edit.undo", json!({})).unwrap(); + assert!(!body_hidden(&h, "One"), "one undo takes back only the drag: the show before it stays"); + assert!(h.app.session.visibility.hidden_origin.is_empty(), "the planes the drag hid are shown again"); + assert!(origin_hidden(&h), "the Origin's eye is not an undo step: it keeps what the drag set"); + h.app.run("edit.undo", json!({})).unwrap(); + assert!(body_hidden(&h, "One"), "the show is its own undo step: undoing it hides One again"); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8a7c928596..bb336aafc0 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -287,6 +287,7 @@ pub(crate) fn owns_pointer(app: &SolveApp, proj: &Proj, p: Pos2) -> bool { && app.tool.is_none() && crate::browser::occurrence_triad_model(app).is_some_and(|t| crate::gizmo::contains_pointer(proj, &t, p))) || crate::place_gizmo::contains_pointer(app, p) + || app.dialog.as_ref().is_some_and(|d| matches!(&d.kind, Kind::Measure { labels, .. } if labels.contains(p))) } /// Draw the manipulator and the value box (called by the viewport after the model is drawn). @@ -308,10 +309,16 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj app.dialog = Some(d); return; } - if let Kind::Measure { result: Some(r), show, .. } = &d.kind { - measure_line(app, painter, proj, r, *show); - app.dialog = Some(d); - return; + if let Kind::Measure { result, show, labels, .. } = &mut d.kind { + labels.drawn = Default::default(); + if !ui.input(|i| i.pointer.primary_down()) { + labels.grab = None; + } + if let Some(r) = result { + measure_line(app, ui, painter, proj, r, *show, labels); + app.dialog = Some(d); + return; + } } // Move: the triad at the pivot; dragging it sets the distances and angles. Its value box // sits beside it and reads the move's main value, so a drag shows its number and typing @@ -1300,9 +1307,85 @@ fn radius_spline_handles(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Pain true } +/// The Measure labels in the view the user dragged away from the part (#8510): the distance's, +/// then X, Y and Z's. They last while the measurement does; another one starts them over. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct MeasureLabels { + /// How far each label was dragged from its own place, in points. + offset: [egui::Vec2; 4], + /// Where each was drawn in the last frame, so a press on one drags it instead of picking + /// under it or navigating. + drawn: [Option; 4], + /// The dragged label's offset when the button went down (while it is held). + grab: Option, +} + +impl MeasureLabels { + /// Whether `p` is on one of the labels drawn in the last frame. + pub(crate) fn contains(&self, p: Pos2) -> bool { + self.drawn.iter().flatten().any(|r| r.contains(p)) + } + + /// How far label `i` was dragged (zero: in its own place). + pub(crate) fn offset(&self, i: usize) -> egui::Vec2 { + self.offset.get(i).copied().unwrap_or(egui::Vec2::ZERO) + } +} + +/// Label `i`, drawn at `rect`, follows a drag of the pointer; a double-click puts it back. +fn drag_label(ui: &mut egui::Ui, labels: &mut MeasureLabels, i: usize, rect: egui::Rect) { + if let Some(drawn) = labels.drawn.get_mut(i) { + *drawn = Some(rect); + } + let r = ui.interact(rect, egui::Id::new(("sc_measure_label", i)), egui::Sense::click_and_drag()); + if r.dragged() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + } else if r.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); + } + let Some(offset) = labels.offset.get_mut(i) else { return }; + if r.double_clicked() { + *offset = egui::Vec2::ZERO; + } else if r.dragged() + && let (Some(from), Some(to)) = (ui.input(|i| i.pointer.press_origin()), r.interact_pointer_pos()) + { + // Where it was at the press, plus how far the pointer went since: the label stays under + // the pointer where it was grabbed. + let start = *labels.grab.get_or_insert(*offset); + let moved = start + (to - from); + // Far enough to leave any view, finite whatever the pointer reports. + if moved.is_finite() { + *offset = moved.clamp(egui::Vec2::splat(-10_000.0), egui::Vec2::splat(10_000.0)); + } + } +} + +/// `rect` moved by a dragged label's `offset`, kept inside the view so it can be dragged back. +fn moved_label(proj: &Proj, rect: egui::Rect, offset: egui::Vec2) -> egui::Rect { + if offset == egui::Vec2::ZERO { + return rect; + } + let bounds = proj.rect.shrink(4.0); + let moved = rect.translate(offset); + let min = egui::pos2( + moved.left().clamp(bounds.left(), (bounds.right() - rect.width()).max(bounds.left())), + moved.top().clamp(bounds.top(), (bounds.bottom() - rect.height()).max(bounds.top())), + ); + egui::Rect::from_min_size(min, rect.size()) +} + /// The measured distance: a line between the ends of the shown measure (the centres for -/// centre to centre, the closest or farthest points) with its length. -fn measure_line(app: &SolveApp, painter: &egui::Painter, proj: &Proj, r: &serde_json::Value, show: Option) { +/// centre to centre, the closest or farthest points) with its length. Its labels can be dragged +/// away from the part; a moved one keeps a leader to what it measures. +fn measure_line( + app: &SolveApp, + ui: &mut egui::Ui, + painter: &egui::Painter, + proj: &Proj, + r: &serde_json::Value, + show: Option, + labels: &mut MeasureLabels, +) { let t = Tokens::get(); let Some((_, d, a, b)) = crate::dialogs::shown_distance(r, show) else { return }; if !a.is_finite() || !b.is_finite() || !d.is_finite() { @@ -1317,15 +1400,20 @@ fn measure_line(app: &SolveApp, painter: &egui::Painter, proj: &Proj, r: &serde_ painter.circle(s, 3.5, t.manipulator, Stroke::new(1.0, Color32::WHITE)); } pick_numbers(painter, sa, sb); - let mid = sa + (sb - sa) * 0.5 + vec2(10.0, -14.0); + let mid = sa + (sb - sa) * 0.5; let galley = painter.layout_no_wrap(crate::prefs::show_mm(app, d, 1), egui::FontId::proportional(crate::theme::Tokens::get().font_label), t.text); - let rect = egui::Rect::from_min_size(mid, galley.size() + vec2(10.0, 6.0)); + let own = egui::Rect::from_min_size(mid + vec2(10.0, -14.0), galley.size() + vec2(10.0, 6.0)); + let rect = moved_label(proj, own, labels.offset(0)); crate::scenario::publish_target("measure:label", rect); if app.preferences.measure_axis_distances { - measure_components(app, painter, proj, a, b, rect); + measure_components(app, ui, painter, proj, a, b, rect, labels); + } + if rect != own { + painter.line_segment([mid, rect.clamp(mid)], Stroke::new(1.0, t.manipulator)); } painter.rect(rect, t.radius_sm.saturating_sub(1), t.overlay, t.control_border_stroke(t.border), egui::StrokeKind::Inside); painter.galley(rect.min + vec2(5.0, 3.0), galley, t.text); + drag_label(ui, labels, 0, rect); } /// "1" and "2" beside the measured line's ends (#6020), as the panel lists the picks: each just @@ -1347,13 +1435,24 @@ fn pick_numbers(painter: &egui::Painter, sa: Pos2, sb: Pos2) { } /// A world-axis-aligned chain between the same two measured points. Zero-length and -/// view-aligned legs still get a label; labels move apart when their projections coincide. -fn measure_components(app: &SolveApp, painter: &egui::Painter, proj: &Proj, a: Vec3, b: Vec3, total: egui::Rect) { +/// view-aligned legs still get a label; labels move apart when their projections coincide, and +/// the user can drag each away (`labels` 1 to 3). +#[allow(clippy::too_many_arguments)] +fn measure_components( + app: &SolveApp, + ui: &mut egui::Ui, + painter: &egui::Painter, + proj: &Proj, + a: Vec3, + b: Vec3, + total: egui::Rect, + labels: &mut MeasureLabels, +) { let t = Tokens::get(); let x = Vec3::new(b.x, a.y, a.z); let y = Vec3::new(b.x, b.y, a.z); let mut occupied = vec![total]; - for (axis, start, end, color) in [("x", a, x, t.axis_x), ("y", x, y, t.axis_y), ("z", y, b, t.axis_z)] { + for (i, (axis, start, end, color)) in [("x", a, x, t.axis_x), ("y", x, y, t.axis_y), ("z", y, b, t.axis_z)].into_iter().enumerate() { let color = color.lerp_to_gamma(t.text, 0.25); let distance = start.dist(end); if !distance.is_finite() { @@ -1401,12 +1500,14 @@ fn measure_components(app: &SolveApp, painter: &egui::Painter, proj: &Proj, a: V } } } + let rect = moved_label(proj, rect, labels.offset(i + 1)); painter.line_segment([at, rect.center()], Stroke::new(0.8, color)); painter.rect(rect, t.radius_sm.saturating_sub(1), t.overlay, Stroke::new(1.0, color), egui::StrokeKind::Inside); painter.galley(rect.min + vec2(5.0, 3.0), galley, t.text); let handle = format!("measure:label:{axis}"); crate::scenario::publish_target(&handle, rect); occupied.push(rect); + drag_label(ui, labels, i + 1, rect); } } diff --git a/crates/ui-egui/src/command_help.rs b/crates/ui-egui/src/command_help.rs index aac64337bd..326df16ead 100644 --- a/crates/ui-egui/src/command_help.rs +++ b/crates/ui-egui/src/command_help.rs @@ -2,19 +2,166 @@ //! tool name when the pointer rests on its button. The names stay as they are; the help is a //! separate message `help.` in the catalogs, so a tool without help keeps its plain //! tooltip. Text only for now; pictures and animations are a later step. A new toolbar command -//! ships with its help: `every_toolbar_command_has_help_or_is_on_the_backlog` fails otherwise. +//! ships with its help: `every_toolbar_command_has_help` fails otherwise. use crate::i18n; /// The commands that have help text. Each one needs a `help.` message in the catalogs. pub const DOCUMENTED: &[&str] = &[ "appearance.assign", + "assembly.bom", + "canvas.decal", + "canvas.edit", + "canvas.insert", + "component.break_link", + "component.capture_position", + "component.create", "component.finish_edit", + "component.from_bodies", + "component.insert_design", + "component.properties", + "component.revert_position", + "component.update_links", + "config.configure", + "config.preview", + "config.rules", + "config.table", + "construct.axis.edge", + "construct.axis.normal_to_face", + "construct.axis.through_cylinder", + "construct.axis.two_planes", + "construct.axis.two_points", + "construct.plane.along_path", + "construct.plane.at_angle", + "construct.plane.midplane", + "construct.plane.offset", + "construct.plane.perpendicular", + "construct.plane.tangent", + "construct.plane.three_points", + "construct.plane.two_edges", + "construct.point.along_path", + "construct.point.center", + "construct.point.edge_plane", + "construct.point.three_planes", + "construct.point.two_edges", + "construct.point.vertex", + "contact.check", + "contact.create", + "contact.disable_all", + "contact.edit", + "contact.enable_all", + "contact.enable_sets", + "contact.list", + "document.inspect", + "document.units", + "edit.checkpoint", + "edit.restore_checkpoint", + "explode.create", "face.delete", + "file.insert_mesh", + "file.insert_step", + "file.save_mesh", + "file.send_to_slicer", + "frame.member", + "inspect.accessibility", + "inspect.center_of_mass", + "inspect.colored_components", + "inspect.compare", + "inspect.continuity", + "inspect.curvature_comb", + "inspect.curvature_map", + "inspect.draft", + "inspect.environment_map", + "inspect.interference", + "inspect.isocurve", + "inspect.measure", + "inspect.minimum_radius", + "inspect.section", + "inspect.zebra", + "joint.as_built", + "joint.cam", + "joint.create", + "joint.drive", + "joint.gear", + "joint.limits", + "joint.motion_link", + "joint.origin", + "joint.rack_pinion", + "joint.reach", + "joint.rigid_group", + "joint.screw", + "library.apply", + "library.export", + "library.inspect", "material.assign", + "material.properties.validate", + "mesh.convert", + "motion.study", + "motion.trace", + "occurrence.ground", "occurrence.move", "parameters.change", + "parameters.export", + "parameters.graph", + "parameters.import", + "parameters.move", + "parts.fastener", + "parts.insert", + "parts.library", + "parts.resize", + "plastic.assign_rule", + "plastic.boss", + "plastic.lip", + "plastic.manage_rules", + "plastic.rest", + "plastic.snap_fit", "pointcloud.insert", + "sheet.convert", + "sheet.export_bend_table", + "sheet.export_dxf", + "sheet.flange", + "sheet.flat_pattern", + "sheet.fold", + "sheet.hem", + "sheet.manage_rules", + "sheet.path_flange", + "sheet.perforate", + "sheet.refold", + "sheet.unfold", + "sketch.arc.center_point", + "sketch.arc.tangent", + "sketch.arc.three_point", + "sketch.arc3d", + "sketch.auto_constrain", + "sketch.auto_constrain_from_datum", + "sketch.blend_curve", + "sketch.break", + "sketch.cancel", + "sketch.centerline", + "sketch.chamfer.distance_angle", + "sketch.chamfer.equal_distance", + "sketch.chamfer.two_distance", + "sketch.circle.center", + "sketch.circle.three_point", + "sketch.circle.three_tangent", + "sketch.circle.two_point", + "sketch.circle.two_tangent", + "sketch.conic", + "sketch.constrainer", + "sketch.constraint.coincident", + "sketch.constraint.collinear", + "sketch.constraint.concentric", + "sketch.constraint.curvature", + "sketch.constraint.equal", + "sketch.constraint.fix", + "sketch.constraint.horizontal_vertical", + "sketch.constraint.midpoint", + "sketch.constraint.parallel", + "sketch.constraint.perpendicular", + "sketch.constraint.polygon", + "sketch.constraint.symmetry", + "sketch.constraint.tangent", + "sketch.construction", "sketch.construction_line", "sketch.construction_line.angle", "sketch.construction_line.bisector", @@ -25,12 +172,71 @@ pub const DOCUMENTED: &[&str] = &[ "sketch.construction_line.tangent_external", "sketch.construction_line.tangent_two", "sketch.construction_line.vertical", + "sketch.copy", + "sketch.create", + "sketch.cut", + "sketch.dimension", + "sketch.edit", + "sketch.ellipse", "sketch.equation_curve", + "sketch.extend", + "sketch.fillet", + "sketch.finish", + "sketch.finish_auto_constrain", + "sketch.fit_curves_to_section", + "sketch.include_3d", + "sketch.infinite", + "sketch.insert_dxf", + "sketch.insert_svg", "sketch.inspect", + "sketch.intersect", + "sketch.intersection_curve", + "sketch.isoparametric_curve", + "sketch.line", + "sketch.line.midpoint", + "sketch.line3d", + "sketch.mirror", + "sketch.move", + "sketch.offset", + "sketch.paste", + "sketch.pattern.circular", + "sketch.pattern.rectangular", + "sketch.point", + "sketch.point3d", + "sketch.polygon.circumscribed", + "sketch.polygon.edge", + "sketch.polygon.inscribed", + "sketch.project", + "sketch.project_to_surface", + "sketch.rectangle.center", + "sketch.rectangle.rounded", + "sketch.rectangle.three_point", + "sketch.rectangle.two_point", + "sketch.scale", + "sketch.slot.arc_center", + "sketch.slot.arc_three_point", + "sketch.slot.center_point", + "sketch.slot.center_to_center", + "sketch.slot.overall", + "sketch.spline.control_point", + "sketch.spline.control_point_5", + "sketch.spline.fit_point", + "sketch.spline3d", + "sketch.spun_profile", + "sketch.text", + "sketch.transform", + "sketch.trim", "solid.align", + "solid.boundary_fill", + "solid.bounding_solid", + "solid.box", "solid.chamfer", + "solid.coil", "solid.combine", + "solid.cone", + "solid.cylinder", "solid.draft", + "solid.emboss", "solid.extrude", "solid.fillet", "solid.hole", @@ -39,19 +245,59 @@ pub const DOCUMENTED: &[&str] = &[ "solid.move", "solid.offset_face", "solid.pattern.circular", + "solid.pattern.path", "solid.pattern.rectangular", + "solid.pipe", "solid.press_pull", "solid.remove", "solid.replace_face", "solid.revolve", + "solid.rib", "solid.scale", "solid.shell", + "solid.sphere", "solid.split_body", "solid.split_face", "solid.sweep", + "solid.thread", + "solid.torus", + "solid.web", + "surface.blend", + "surface.boundary", + "surface.extend", + "surface.extrude", + "surface.fair", + "surface.loft", + "surface.match", + "surface.offset", + "surface.patch", + "surface.reverse_normal", + "surface.revolve", + "surface.ruled", + "surface.stitch", + "surface.sweep", + "surface.thicken", + "surface.trim", + "surface.unstitch", + "surface.untrim", "timeline.compute_all", "timeline.delete", + "timeline.dependents", "timeline.play", + "view.apply", + "view.delete", + "view.fit", + "view.grid", + "view.ground_shadow", + "view.home", + "view.list", + "view.navigate", + "view.navigation_bar", + "view.rename", + "view.save", + "view.spin", + "view.tool", + "view.visual_style", ]; /// The help for `command` in the current language, when it has any. @@ -104,257 +350,6 @@ mod tests { .collect() } - /// Toolbar commands written before every tool needed help (#2758). The list only shrinks: write - /// the help for one of these and take it off; a new toolbar command never goes on it. - const BACKLOG: &[&str] = &[ - "assembly.bom", - "canvas.decal", - "canvas.edit", - "canvas.insert", - "component.break_link", - "component.capture_position", - "component.create", - "component.from_bodies", - "component.insert_design", - "component.properties", - "component.revert_position", - "component.update_links", - "config.configure", - "config.preview", - "config.rules", - "config.table", - "construct.axis.edge", - "construct.axis.normal_to_face", - "construct.axis.through_cylinder", - "construct.axis.two_planes", - "construct.axis.two_points", - "construct.plane.along_path", - "construct.plane.at_angle", - "construct.plane.midplane", - "construct.plane.offset", - "construct.plane.perpendicular", - "construct.plane.tangent", - "construct.plane.three_points", - "construct.plane.two_edges", - "construct.point.along_path", - "construct.point.center", - "construct.point.edge_plane", - "construct.point.three_planes", - "construct.point.two_edges", - "construct.point.vertex", - "contact.check", - "contact.create", - "contact.disable_all", - "contact.edit", - "contact.enable_all", - "contact.enable_sets", - "contact.list", - "document.inspect", - "document.units", - "edit.checkpoint", - "edit.restore_checkpoint", - "explode.create", - "file.insert_mesh", - "file.insert_step", - "file.save_mesh", - "file.send_to_slicer", - "frame.member", - "inspect.accessibility", - "inspect.center_of_mass", - "inspect.colored_components", - "inspect.compare", - "inspect.continuity", - "inspect.curvature_comb", - "inspect.curvature_map", - "inspect.draft", - "inspect.environment_map", - "inspect.interference", - "inspect.isocurve", - "inspect.measure", - "inspect.minimum_radius", - "inspect.section", - "inspect.zebra", - "joint.as_built", - "joint.cam", - "joint.create", - "joint.drive", - "joint.gear", - "joint.limits", - "joint.motion_link", - "joint.origin", - "joint.rack_pinion", - "joint.reach", - "joint.rigid_group", - "joint.screw", - "library.apply", - "library.export", - "library.inspect", - "material.properties.validate", - "mesh.convert", - "motion.study", - "motion.trace", - "occurrence.ground", - "parameters.export", - "parameters.graph", - "parameters.import", - "parameters.move", - "parts.fastener", - "parts.insert", - "parts.library", - "parts.resize", - "plastic.assign_rule", - "plastic.boss", - "plastic.lip", - "plastic.manage_rules", - "plastic.rest", - "plastic.snap_fit", - "sheet.convert", - "sheet.export_bend_table", - "sheet.export_dxf", - "sheet.flange", - "sheet.flat_pattern", - "sheet.fold", - "sheet.hem", - "sheet.manage_rules", - "sheet.path_flange", - "sheet.perforate", - "sheet.refold", - "sheet.unfold", - "sketch.arc.center_point", - "sketch.arc.tangent", - "sketch.arc.three_point", - "sketch.arc3d", - "sketch.auto_constrain", - "sketch.auto_constrain_from_datum", - "sketch.blend_curve", - "sketch.break", - "sketch.cancel", - "sketch.centerline", - "sketch.chamfer.distance_angle", - "sketch.chamfer.equal_distance", - "sketch.chamfer.two_distance", - "sketch.circle.center", - "sketch.circle.three_point", - "sketch.circle.three_tangent", - "sketch.circle.two_point", - "sketch.circle.two_tangent", - "sketch.conic", - "sketch.constrainer", - "sketch.constraint.coincident", - "sketch.constraint.collinear", - "sketch.constraint.concentric", - "sketch.constraint.curvature", - "sketch.constraint.equal", - "sketch.constraint.fix", - "sketch.constraint.horizontal_vertical", - "sketch.constraint.midpoint", - "sketch.constraint.parallel", - "sketch.constraint.perpendicular", - "sketch.constraint.polygon", - "sketch.constraint.symmetry", - "sketch.constraint.tangent", - "sketch.construction", - "sketch.copy", - "sketch.create", - "sketch.cut", - "sketch.dimension", - "sketch.edit", - "sketch.ellipse", - "sketch.extend", - "sketch.fillet", - "sketch.finish", - "sketch.finish_auto_constrain", - "sketch.fit_curves_to_section", - "sketch.include_3d", - "sketch.infinite", - "sketch.insert_dxf", - "sketch.insert_svg", - "sketch.intersect", - "sketch.intersection_curve", - "sketch.isoparametric_curve", - "sketch.line", - "sketch.line.midpoint", - "sketch.line3d", - "sketch.mirror", - "sketch.move", - "sketch.offset", - "sketch.paste", - "sketch.pattern.circular", - "sketch.pattern.rectangular", - "sketch.point", - "sketch.point3d", - "sketch.polygon.circumscribed", - "sketch.polygon.edge", - "sketch.polygon.inscribed", - "sketch.project", - "sketch.project_to_surface", - "sketch.rectangle.center", - "sketch.rectangle.rounded", - "sketch.rectangle.three_point", - "sketch.rectangle.two_point", - "sketch.scale", - "sketch.slot.arc_center", - "sketch.slot.arc_three_point", - "sketch.slot.center_point", - "sketch.slot.center_to_center", - "sketch.slot.overall", - "sketch.spline.control_point", - "sketch.spline.control_point_5", - "sketch.spline.fit_point", - "sketch.spline3d", - "sketch.spun_profile", - "sketch.text", - "sketch.transform", - "sketch.trim", - "solid.boundary_fill", - "solid.bounding_solid", - "solid.box", - "solid.coil", - "solid.cone", - "solid.cylinder", - "solid.emboss", - "solid.pattern.path", - "solid.pipe", - "solid.rib", - "solid.sphere", - "solid.thread", - "solid.torus", - "solid.web", - "surface.blend", - "surface.boundary", - "surface.extend", - "surface.extrude", - "surface.fair", - "surface.loft", - "surface.match", - "surface.offset", - "surface.patch", - "surface.reverse_normal", - "surface.revolve", - "surface.ruled", - "surface.stitch", - "surface.sweep", - "surface.thicken", - "surface.trim", - "surface.unstitch", - "surface.untrim", - "timeline.dependents", - "view.apply", - "view.delete", - "view.fit", - "view.grid", - "view.ground_shadow", - "view.home", - "view.list", - "view.navigate", - "view.navigation_bar", - "view.rename", - "view.save", - "view.spin", - "view.tool", - "view.visual_style", - ]; - /// Every command the ribbon offers people, gathered the way the toolbar lays out its panels. A /// planned command only says "not supported yet"; it gets its help when it is built. fn toolbar_commands() -> BTreeSet<&'static str> { @@ -369,17 +364,13 @@ mod tests { } #[test] - fn every_toolbar_command_has_help_or_is_on_the_backlog() { - let toolbar = toolbar_commands(); - let missing: Vec<_> = toolbar.iter().filter(|id| !DOCUMENTED.contains(id) && !BACKLOG.contains(id)).collect(); - let stale: Vec<_> = BACKLOG.iter().filter(|id| !toolbar.contains(*id) || DOCUMENTED.contains(id)).collect(); + fn every_toolbar_command_has_help() { + let missing: Vec<_> = toolbar_commands().into_iter().filter(|id| !DOCUMENTED.contains(id)).collect(); assert!( - missing.is_empty() && stale.is_empty(), + missing.is_empty(), "toolbar commands without help: {missing:?}. Add `help.` (two short sentences: what the tool does, \ - then when to use it) to a file in assets/locales/pending/ and the id to DOCUMENTED. Take these off the backlog \ - (documented now, or not on the toolbar): {stale:?}" + then when to use it) to a file in assets/locales/pending/ and the id to DOCUMENTED." ); - assert!(BACKLOG.is_sorted()); } #[test] @@ -420,7 +411,7 @@ mod tests { for id in DOCUMENTED { assert!(help(id).is_some_and(|text| !text.is_empty()), "{id}"); } - assert!(help("solid.rib").is_none()); + assert!(help("sketch.drag").is_none()); } #[test] @@ -428,7 +419,7 @@ mod tests { assert_eq!(row_tip("solid.shell", "Shell", Some("S"), None).as_deref(), Some("Shell (S)")); assert_eq!(row_tip("solid.hole", "Hole", None, None).as_deref(), Some("Hole")); assert_eq!(row_tip("solid.hole", "Hole", None, Some("Select a face")).as_deref(), Some("Select a face")); - assert_eq!(row_tip("solid.rib", "Rib", None, None), None); - assert_eq!(row_tip("solid.rib", "Rib", None, Some("No body")).as_deref(), Some("No body")); + assert_eq!(row_tip("sketch.drag", "Drag Sketch Geometry", None, None), None); + assert_eq!(row_tip("sketch.drag", "Drag Sketch Geometry", None, Some("No body")).as_deref(), Some("No body")); } } diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs index 643f061eb0..3d770005b2 100644 --- a/crates/ui-egui/src/context_menu.rs +++ b/crates/ui-egui/src/context_menu.rs @@ -15,7 +15,7 @@ use crate::ui_kit::{Container, Surface}; use egui::{Color32, Pos2, Rect, Sense, Vec2, pos2, vec2}; use serde::Serialize; use serde_json::{Value, json}; -use solidcraft_engine::Sel; +use solidcraft_engine::{Sel, doc::FeatureKind}; use crate::SolveApp; @@ -65,6 +65,8 @@ pub enum Target { Units, /// Insert › Insert Design: the designs open in other tabs, and Browse… (#8570). InsertDesign, + /// Model › Edit Sketch: choose an existing sketch when none is uniquely selected. + EditSketch, /// View › Visual style: the four styles, the current one marked (#8868). VisualStyle, /// View › Navigation tool: Orbit, Pan, Zoom; the latched one marked (#8868). @@ -435,6 +437,17 @@ pub fn items(app: &SolveApp, target: &Target) -> Vec { .collect(), Target::NamedView { name } => crate::browser::view_items(app, name), Target::InsertDesign => insert_design_items(app), + Target::EditSketch => { + let items: Vec = app + .session + .doc + .features + .iter() + .filter(|f| matches!(f.kind, FeatureKind::Sketch { .. })) + .map(|f| act("ui.editSketch", &f.name, "sketch").with(json!({"sketch": f.id}))) + .collect(); + if items.is_empty() { vec![act("ui.editSketch", app.tr("ui.edit_sketch_empty"), "sketch").on(false)] } else { items } + } Target::VisualStyle => crate::navigation_bar::VISUAL_STYLES .iter() .enumerate() @@ -1155,7 +1168,7 @@ fn sketch_entity_items(app: &SolveApp) -> Vec { v.push(cmd(app, "sketch.circle.center", "Circle")); v.push(cmd(app, "sketch.dimension", "Sketch Dimension")); v.push(Item::sep()); - v.push(act("ui.paste", "Paste", "paste").key("Ctrl+V").on(app.session.sketch_clipboard.is_some())); + v.push(act("ui.paste", "Paste", "paste").key("Ctrl+V").on(crate::edit_clipboard::can_paste_sketch(app))); return v; } let ents: Vec = curves.iter().chain(points.iter()).cloned().collect(); @@ -1220,7 +1233,7 @@ fn sketch_entity_items(app: &SolveApp) -> Vec { // The clipboard beside Delete, as in every menu. v.push(act("ui.cut", "Cut", "cut").key("Ctrl+X")); v.push(act("ui.copy", "Copy", "copy").key("Ctrl+C")); - v.push(act("ui.paste", "Paste", "paste").key("Ctrl+V").on(app.session.sketch_clipboard.is_some())); + v.push(act("ui.paste", "Paste", "paste").key("Ctrl+V").on(crate::edit_clipboard::can_paste_sketch(app))); v.push(act("ui.delete", "Delete", "delete").key("Del")); v.push(cmd(app, "sketch.dimension", "Sketch Dimension").with(json!({ "entities": ents })).on(ents.len() <= 2)); // One arc alone: Sketch Dimension gives its radius, this its length along the curve (#6039). @@ -1865,8 +1878,13 @@ pub fn toggle_visibility(app: &mut SolveApp) { } /// Rename what is selected (F2): one body, sketch or feature, construction plane, component -/// picked in the browser, or canvas. +/// picked in the browser, canvas, or a group whose Browser row was clicked. pub fn rename_selection(app: &mut SolveApp, at: Pos2) -> bool { + // A clicked group row takes precedence, as it does for V: groups have no selection of their own. + if let Some(g) = crate::browser::picked_group(app) { + start_rename(app, RenameWhat::Group(g), at); + return true; + } let what = match (app.session.selection.as_slice(), app.tree.picked_components.as_slice(), app.tree.picked_canvases.as_slice()) { ([Sel::Body { name }], [], []) => RenameWhat::Body(name.clone()), ([Sel::Feature { id }], [], []) => RenameWhat::Feature(*id), diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 17c43ccbab..b775aebb50 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -216,6 +216,18 @@ fn key_from(name: &str) -> Option { /// Run one command as `engine.execute` does: through `app.run`, then the first-body fit and the /// camera the command asked for. fn execute(app: &mut SolveApp, id: &str, params: Value) -> Result { + // User bindings live in the UI. A bridged agent needs the same key the menus show, + // including null for a cleared binding; the standalone engine keeps its defaults. + if id == "engine.commands" { + let mut commands = app.session.execute(id, ¶ms).map_err(|e| e.to_string())?; + if let Some(rows) = commands.as_array_mut() { + for row in rows.iter_mut().filter_map(Value::as_object_mut) { + let key = row.get("id").and_then(Value::as_str).and_then(|id| crate::keymap::effective(app, id)); + row.insert("shortcut".into(), json!(key)); + } + } + return Ok(commands); + } // `view.pick` is answered by the viewport, not the engine: pixels and rays need the // window's camera and pick index, and the command must not touch the document, the // selection or the undo stack. Intercepted here so `engine.execute` and `engine.batch` @@ -453,6 +465,10 @@ pub fn view_png(app: &SolveApp, w: u32, h: u32) -> Result, String> { } pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { + crate::agent_chat::with_tool_request(app, req.chat_turn.as_ref(), |app| handle_request(app, ctx, req)) +} + +fn handle_request(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { let level = match request_param_check(&req.method, &req.params) { Ok(level) => level, Err(e) => return err(e), @@ -594,7 +610,7 @@ fn dispatch(app: &mut SolveApp, ctx: &egui::Context, method: &str, p: &Value) -> app.follow_view_request(false); wrap(r) } - "engine.commands" => wrap(app.session.execute("engine.commands", &json!({})).map_err(|e| e.to_string())), + "engine.commands" => wrap(execute(app, "engine.commands", json!({}))), "document.inspect" => wrap(app.session.execute("document.inspect", p).map_err(|e| e.to_string())), "document.revision" => wrap(app.session.execute("document.revision", p).map_err(|e| e.to_string())), // The connection already counts as subscribed (`DesignWatch::join`), and its events start @@ -836,8 +852,22 @@ fn dispatch(app: &mut SolveApp, ctx: &egui::Context, method: &str, p: &Value) -> app.synthetic.push(egui::Event::PointerMoved(a)); } let steps = f("steps").unwrap_or(10.0).clamp(1.0, 200.0) as usize; - for k in 1..=steps { - app.synthetic.push(egui::Event::PointerMoved(a + (z - a) * (k as f32 / steps as f32))); + // `via`: [[x, y], ...] the pointer passes through on its way (a lasso's loop, #4376). + let via: Vec = p + .get("via") + .and_then(Value::as_array) + .into_iter() + .flatten() + .take(500) + .filter_map(|q| Some(egui::pos2(q.get(0)?.as_f64()? as f32, q.get(1)?.as_f64()? as f32))) + .filter(|q| q.is_finite()) + .collect(); + let mut from = a; + for to in via.into_iter().chain([z]) { + for k in 1..=steps { + app.synthetic.push(egui::Event::PointerMoved(from + (to - from) * (k as f32 / steps as f32))); + } + from = to; } // `hold_end`: frames to keep the button down at the end (to look at a drag's snap). for _ in 0..f("hold_end").unwrap_or(0.0).clamp(0.0, 600.0) as usize { diff --git a/crates/ui-egui/src/control_job.rs b/crates/ui-egui/src/control_job.rs index 33829820fd..5b8f7bf71f 100644 --- a/crates/ui-egui/src/control_job.rs +++ b/crates/ui-egui/src/control_job.rs @@ -73,7 +73,15 @@ const ANSWERED_ON_A_COPY: &[&str] = &["file.export", "file.save_mesh"]; /// body, an inspection that measures them, #4604; a clearance or interference check of every pair /// of bodies, minutes on an assembly of 150): they run on a copy too, whatever the preference, /// since an answer from the design as it was when asked has no order to keep. -const READ_ON_A_COPY: &[&str] = &["inspect.measure", "document.inspect", "inspect.clearance", "inspect.interference"]; +const READ_ON_A_COPY: &[&str] = &[ + "inspect.measure", + "document.inspect", + "inspect.clearance", + "inspect.interference", + // Physical properties of a component or placement: every body of every placement (#8706). + "component.properties", + "occurrence.properties", +]; /// Exports and readings computing at once; more run here. const MAX_ANSWERING: usize = 4; @@ -413,9 +421,19 @@ impl SolveApp { if let Some(level) = level { copy.param_check = level; } + // The copy has no history and no file (#9624): an inspection reports the design's own. + let s = &self.session; + let own = (id == "document.inspect").then(|| (s.undo.len(), s.redo.len(), s.path.clone())); let rx = spawn(scope, move || { let mut reply = match copy.execute(id, ¶ms) { - Ok(v) => json!({"ok": true, "result": v}), + Ok(mut v) => { + if let (Some((undo, redo, path)), Some(o)) = (own, v.as_object_mut()) { + o.insert("undo".into(), json!(undo)); + o.insert("redo".into(), json!(redo)); + o.insert("path".into(), json!(path)); + } + json!({"ok": true, "result": v}) + } Err(e) => json!({"ok": false, "error": e.to_string()}), }; if let Some(level) = level { diff --git a/crates/ui-egui/src/deferred.rs b/crates/ui-egui/src/deferred.rs index d9e945a0be..ab75509c75 100644 --- a/crates/ui-egui/src/deferred.rs +++ b/crates/ui-egui/src/deferred.rs @@ -66,6 +66,9 @@ pub struct DeleteCheck { /// When it started (ms). pub(crate) started: f64, then: AfterCheck, + /// What the check computes, for the delete itself to find instead of building it again on + /// the window thread (#9917). + kept: std::sync::Arc, /// The design's revision it was computed for: a design changed meanwhile checks again. revision: u64, } @@ -97,8 +100,8 @@ impl AfterCheck { } } - /// Carry on with the check's replies. - fn finish(self, app: &mut SolveApp, replies: Vec>) { + /// Carry on with the check's replies, and what it `kept` (when it ran ahead) for the delete. + fn finish(self, app: &mut SolveApp, replies: Vec>, kept: Option>) { match self { AfterCheck::Timeline { features, groups } => { let reports: Vec = replies.into_iter().map(Result::unwrap_or_default).collect(); @@ -106,7 +109,7 @@ impl AfterCheck { } AfterCheck::Selection(params) => { let report = replies.into_iter().next().unwrap_or_else(|| Err("the delete was not checked".into())); - crate::delete::checked(app, params, report); + crate::delete::checked(app, params, report, kept); } } } @@ -186,6 +189,7 @@ impl SolveApp { && self.file_picker.is_none() { let ahead = self.session.ahead(); + let kept = ahead.kept(); let (tx, rx) = std::sync::mpsc::channel(); let checks = commands.clone(); let spawned = std::thread::Builder::new().name("delete check".into()).spawn(move || { @@ -193,17 +197,17 @@ impl SolveApp { let _ = tx.send(replies); }); if spawned.is_ok() { - let check = DeleteCheck { done: rx, started: crate::now_ms(), then, revision: self.session.revision }; + let check = DeleteCheck { done: rx, started: crate::now_ms(), then, revision: self.session.revision, kept }; // As with the history's commands, zero always leaves it for a later frame. match (wait.is_zero(), check.done.recv_timeout(wait)) { - (false, Ok(replies)) => check.then.finish(self, replies), + (false, Ok(replies)) => check.then.finish(self, replies, Some(check.kept.clone())), _ => self.delete_check = Some(check), } return; } } let replies = commands.iter().map(|(id, params)| self.session.execute(id, params).map_err(|e| e.to_string())).collect(); - then.finish(self, replies); + then.finish(self, replies, None); } /// Each frame: a delete whose check is in carries on (checked again when the design changed @@ -222,7 +226,7 @@ impl SolveApp { match replies { None => self.set_status("the delete could not be checked", true), Some(_) if check.revision != self.session.revision => check.then.restart(self), - Some(replies) => check.then.finish(self, replies), + Some(replies) => check.then.finish(self, replies, Some(check.kept)), } } diff --git a/crates/ui-egui/src/delete.rs b/crates/ui-egui/src/delete.rs index ac35f4109e..cee70ad277 100644 --- a/crates/ui-egui/src/delete.rs +++ b/crates/ui-egui/src/delete.rs @@ -2,7 +2,10 @@ //! the viewport or the browser goes in one undoable step through `selection.delete`. When it //! takes other features with it or makes some fail, a confirmation lists them first. +use std::sync::Arc; + use serde_json::{Value, json}; +use solidcraft_engine::doc::KeptResults; use crate::SolveApp; use crate::ui_kit; @@ -15,6 +18,8 @@ const MAX_LISTED: usize = 20; pub struct Pending { pub params: Value, pub report: Value, + /// What the dry run computed ahead, for the delete to find (#9917). + pub kept: Option>, } /// What Delete acts on now: the selection (bodies not locked), plus the components picked in @@ -71,6 +76,11 @@ pub fn delete_selection(app: &mut SolveApp) { request(app, params); } +/// Whether Delete has nothing to act on: nothing selected, and no joint open in Edit Joint. +pub fn nothing_to_delete(app: &SolveApp) -> bool { + is_empty(&selection_params(app)) && edited_joint(app).is_none() +} + /// The joint the open Edit Joint dialog edits. fn edited_joint(app: &SolveApp) -> Option { match app.dialog.as_ref().map(|d| &d.kind) { @@ -122,7 +132,7 @@ pub fn request(app: &mut SolveApp, params: Value) { } /// [`request`] once its dry run's report is in. -pub(crate) fn checked(app: &mut SolveApp, params: Value, report: Result) { +pub(crate) fn checked(app: &mut SolveApp, params: Value, report: Result, kept: Option>) { let report = match report { Ok(r) => r, Err(e) => { @@ -136,14 +146,16 @@ pub(crate) fn checked(app: &mut SolveApp, params: Value, report: Result>) { + match app.run_with_kept(kept, |app| app.run("selection.delete", params.clone())) { Ok(r) => { app.tree.picked_components.clear(); app.tree.picked_canvases.clear(); @@ -160,7 +172,7 @@ fn run(app: &mut SolveApp, params: &Value) { pub fn confirm(app: &mut SolveApp, accept: bool) -> bool { let Some(p) = app.menu.confirm.take() else { return false }; if accept { - run(app, &p.params); + run(app, &p.params, p.kept); } true } diff --git a/crates/ui-egui/src/design_properties.rs b/crates/ui-egui/src/design_properties.rs index 92a66eb5c8..c20fe7108e 100644 --- a/crates/ui-egui/src/design_properties.rs +++ b/crates/ui-egui/src/design_properties.rs @@ -9,9 +9,12 @@ //! about; both are `ui_kit::Window` cards. While the card is open, [`overlay`] marks the centre //! of mass in the view with the principal axes standing out of it. +use std::sync::Arc; + use egui::{Align2, FontId, Stroke}; use serde_json::{Map, Value, json}; use solidcraft_engine::doc::metadata::{DESCRIPTION, Owner, keys}; +use solidcraft_engine::doc::progress::Progress; use solidcraft_engine::geom::Vec3; use crate::SolveApp; @@ -127,12 +130,14 @@ pub fn open_object(app: &mut SolveApp, command: &str, params: &Value, fallback: close_object(app); // A large assembly takes seconds to measure, which froze the window (#5203): measured on a // copy beside it, the card shows when done. Headless and in tests (no wait set), here. + // The worker reports how many bodies it has measured, and stops when the card closes (#8706). + let progress = Arc::new(Progress::default()); if let Some(wait) = app.apply_off_thread - && let Some(rx) = measure_beside(app.session.scratch(), command, params) + && let Some(rx) = measure_beside(app.session.scratch(), command, params, progress.clone()) { match rx.recv_timeout(wait) { Ok(reply) => opened(app, reply, fallback), - Err(std::sync::mpsc::RecvTimeoutError::Timeout) => app.menu.props_measuring = Some(Measuring { fallback, rx }), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => app.menu.props_measuring = Some(Measuring { fallback, rx, progress }), Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => app.set_status(app.tr("ui.properties.measuring_failed").to_string(), true), } return; @@ -148,10 +153,27 @@ pub fn close_object(app: &mut SolveApp) { } /// A Properties measurement running beside the window (#5203): the card's title when the reply -/// has no name, and the reply to come. +/// has no name, the reply to come, and how far the worker is (#8706). pub struct Measuring { fallback: String, rx: std::sync::mpsc::Receiver>, + /// The worker's progress; cancelling it stops the measuring at its next body. + progress: Arc, +} + +impl Measuring { + /// Bodies measured so far and how many there are (none before the worker counts them). + pub fn bodies(&self) -> Option<(usize, usize)> { + self.progress.work().filter(|(stage, _, _)| *stage == "measuring").map(|(_, done, total)| (done, total)) + } +} + +impl Drop for Measuring { + /// A card closed (or a design switched away from) stops its measuring, which on a large + /// assembly would otherwise run on for minutes (#8706). + fn drop(&mut self) { + self.progress.cancel(); + } } /// Show `reply` in the card, titled with its name (else `fallback`); a refusal goes to the @@ -166,15 +188,22 @@ fn opened(app: &mut SolveApp, reply: Result, fallback: String) { } } -/// Run `command` on `copy` on a worker; the reply comes on the returned channel. +/// Run `command` on `copy` on a worker, watched by `progress`; the reply comes on the returned +/// channel. #[cfg(not(target_arch = "wasm32"))] -fn measure_beside(mut copy: solidcraft_engine::Session, command: &str, params: &Value) -> Option>> { +fn measure_beside( + mut copy: solidcraft_engine::Session, + command: &str, + params: &Value, + progress: Arc, +) -> Option>> { let (tx, rx) = std::sync::mpsc::channel(); let (command, params) = (command.to_string(), params.clone()); std::thread::Builder::new() .name("properties".into()) .spawn(move || { - let _ = tx.send(copy.execute(&command, ¶ms).map_err(|e| e.to_string())); + let reply = solidcraft_engine::doc::progress::watch(progress, || copy.execute(&command, ¶ms)); + let _ = tx.send(reply.map_err(|e| e.to_string())); }) .ok()?; Some(rx) @@ -182,26 +211,40 @@ fn measure_beside(mut copy: solidcraft_engine::Session, command: &str, params: & /// No threads on the web: measured here. #[cfg(target_arch = "wasm32")] -fn measure_beside(_copy: solidcraft_engine::Session, _command: &str, _params: &Value) -> Option>> { +fn measure_beside( + _copy: solidcraft_engine::Session, + _command: &str, + _params: &Value, + _progress: Arc, +) -> Option>> { None } /// While a measurement runs beside the window: a card saying so (Close drops it), and the /// Properties card in its place once the reply is in. fn show_measuring(app: &mut SolveApp, ctx: &egui::Context) { - let Some(m) = app.menu.props_measuring.take() else { return }; + let Some(mut m) = app.menu.props_measuring.take() else { return }; match m.rx.try_recv() { - Ok(reply) => return opened(app, reply, m.fallback), + Ok(reply) => return opened(app, reply, std::mem::take(&mut m.fallback)), Err(std::sync::mpsc::TryRecvError::Disconnected) => return app.set_status(app.tr("ui.properties.measuring_failed").to_string(), true), Err(std::sync::mpsc::TryRecvError::Empty) => {} } ctx.request_repaint_after(std::time::Duration::from_millis(100)); - let (heading, text) = (app.tr("ui.properties").to_string(), app.tr("ui.properties.measuring").to_string()); + // How far it is, once the worker has counted the bodies (#8706). + let bodies = m.bodies(); + let heading = app.tr("ui.properties").to_string(); + let text = match bodies { + Some((done, total)) => crate::i18n::format("ui.properties.measuring_bodies", &[("done", &done.to_string()), ("total", &total.to_string())]), + None => app.tr("ui.properties.measuring").to_string(), + }; let (closed, action) = ui_kit::Window::new(egui::Id::new("sc_props"), "settings", &heading).actions("", "Close", None).show(ctx, true, |ui| { ui.horizontal(|ui| { ui.spinner(); ui_kit::text(ui, &text, ui_kit::Text::Note("measuring")); }); + if let Some((done, total)) = bodies.filter(|(_, total)| *total > 0) { + ui_kit::progress(ui, "properties_measuring", done as f32 / total as f32); + } }); if !(closed || action == ui_kit::Action::Cancel) { app.menu.props_measuring = Some(m); @@ -591,7 +634,7 @@ mod tests { assert_eq!(m.rx.recv().unwrap().unwrap(), here, "the copy measures what the design here does"); let (tx, rx) = std::sync::mpsc::channel(); - h.app.menu.props_measuring = Some(super::Measuring { fallback: String::new(), rx }); + h.app.menu.props_measuring = Some(super::Measuring { fallback: String::new(), rx, progress: Default::default() }); h.frames(2); assert!(h.widget("Measuring…").is_some() && h.app.menu.props.is_none()); tx.send(Ok(here.clone())).unwrap(); @@ -599,12 +642,54 @@ mod tests { assert_eq!(h.app.menu.props.as_ref().map(|(_, v)| v), Some(&here)); assert!(h.widget("Moment of inertia").is_some()); + // #8706: while it measures, the card says how many bodies are done, and Close stops the + // worker rather than leaving it to measure on for minutes. let (_tx, rx) = std::sync::mpsc::channel::>(); super::close_object(&mut h.app); - h.app.menu.props_measuring = Some(super::Measuring { fallback: String::new(), rx }); + let progress = std::sync::Arc::new(solidcraft_engine::doc::progress::Progress::default()); + h.app.menu.props_measuring = Some(super::Measuring { fallback: String::new(), rx, progress: progress.clone() }); + solidcraft_engine::doc::progress::watch(progress.clone(), || { + if let Some(w) = solidcraft_engine::kernel::watch::current() { + w.progress("measuring", 3, 10); + } + }); h.frames(2); + assert!(h.widget("Measuring… 3 of 10 bodies").is_some()); h.step(&json!({"widget": "Close"})).unwrap(); assert!(h.app.menu.props_measuring.is_none() && h.app.menu.props.is_none(), "Close drops the measurement"); + assert!(progress.is_cancelled(), "Close stops the worker"); + } + + /// #8706: Properties of an assembly from the Browser's menu returns at once with the card + /// measuring on a copy, and the result (the same a script gets) fills it in. + #[test] + fn assembly_properties_from_the_menu_measure_off_the_window_thread() { + let mut h = Harness::new(); + let block = h.app.run("component.create", json!({"name": "Block"})).unwrap()["component"].as_u64().unwrap(); + h.app.run("solid.box", json!({"length": 10, "width": 10, "height": 10})).unwrap(); + for i in 1..12 { + h.app.run("occurrence.copy", json!({"component": block, "translate": [20 * i, 0, 0]})).unwrap(); + } + h.app.apply_off_thread = Some(std::time::Duration::ZERO); + let items = crate::context_menu::items(&h.app, &crate::context_menu::Target::Component { id: 0 }); + let item = items.iter().find(|i| i.label == "Properties").expect("Properties item"); + crate::context_menu::run_item(&mut h.app, item, egui::pos2(0.0, 0.0)); + assert!(h.app.menu.props_measuring.is_some(), "the menu returns at once, the card measuring"); + assert!(h.app.menu.props.is_none()); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(300); + while h.app.menu.props.is_none() { + assert!(std::time::Instant::now() < deadline, "the measurements never came in"); + assert!(h.app.menu.props_measuring.is_some(), "still measuring until the result is in"); + h.frames(1); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(h.app.menu.props_measuring.is_none()); + let (title, v) = h.app.menu.props.clone().unwrap(); + assert_eq!(title, h.app.session.doc.name); + assert_eq!(v["body_count"], 12); + assert!((v["total"]["volume_mm3"].as_f64().unwrap() - 12_000.0).abs() < 1e-6, "{v}"); + let script = h.app.session.execute("component.properties", &json!({"component": 0})).unwrap(); + assert_eq!(v, script); } #[test] diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 9bc2cdb49f..d10b961b60 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -107,20 +107,20 @@ crate::dialog_descriptor::dialog_descriptors! { pub enum Kind { Shell("solid.shell", "SHELL", [("Faces/Body", FACES, true)], FeatureKind::Shell { thickness, .. }) { - thickness: String = "2 mm".into() => thickness.to_string(), + thickness: String = "2 mm".into() => shown_expr(thickness, ValueKind::Length), } => json!({"thickness": thickness}); Draft("solid.draft", "DRAFT", [("Faces", FACES, true), ("Neutral plane", PLANES | PLANAR_FACES, false)], FeatureKind::Draft { angle, .. }) { - angle: String = "5 deg".into() => angle.to_string(), + angle: String = "5 deg".into() => shown_expr(angle, ValueKind::Angle), } => json!({"angle": angle}); // A point picked as well puts the plane through it instead of at the distance. OffsetPlane("construct.plane.offset", "OFFSET PLANE", [("Plane", PLANES | PLANAR_FACES, false), ("Through point", selection::VERTICES | selection::POINTS, false)], FeatureKind::ConstructionPlane { plane: PlaneRef::Offset { distance, .. } }) { - offset: String = "0 mm".into() => distance.to_string(), + offset: String = "0 mm".into() => shown_expr(distance, ValueKind::Length), } => json!({"offset": offset}); AnglePlane("construct.plane.at_angle", "PLANE AT ANGLE", [("Plane", PLANES | PLANAR_FACES, false), ("Axis", AXIS_PICKS, false)], FeatureKind::ConstructionPlane { plane: PlaneRef::AtAngle { angle, .. } }) { - angle: String = "45 deg".into() => angle.to_string(), + angle: String = "45 deg".into() => shown_expr(angle, ValueKind::Angle), } => json!({"angle": angle}); Scale("solid.scale", "SCALE", [("Bodies", BODIES, true)], FeatureKind::Scale { factor, .. }) { @@ -128,21 +128,21 @@ crate::dialog_descriptor::dialog_descriptors! { } => json!({"factor": factor}); OffsetFaces("solid.offset_face", "OFFSET FACE", [("Faces", FACES, true)], FeatureKind::OffsetFace { distance, .. }) { - distance: String = "2 mm".into() => distance.to_string(), + distance: String = "2 mm".into() => shown_expr(distance, ValueKind::Length), } => json!({"distance": distance}); Stock("solid.bounding_solid", "STOCK", [("Bodies (all if none)", BODIES, true)], FeatureKind::BoundingSolid { margin, .. }) { - margin: String = "2 mm".into() => margin.to_string(), + margin: String = "2 mm".into() => shown_expr(margin, ValueKind::Length), } => json!({"margin": margin}); Pipe("solid.pipe", "PIPE", [("Path", CURVES, true)], FeatureKind::Pipe { diameter, wall, operation, .. }) { - diameter: String = "5 mm".into() => diameter.to_string(), - wall: String = String::new() => wall.as_deref().map(str::to_string).unwrap_or_default(), + diameter: String = "5 mm".into() => shown_expr(diameter, ValueKind::Length), + wall: String = String::new() => wall.as_deref().map(|e| shown_expr(e, ValueKind::Length)).unwrap_or_default(), operation: usize = 0 => op_index(operation), } => json!({"diameter": diameter, "operation": OPS.get(*operation).copied().unwrap_or("new")}); Emboss("solid.emboss", "EMBOSS", [("Profiles", PROFILES, true), ("Target Face (optional)", FACES, false)], FeatureKind::Emboss { depth, deboss, .. }) { - depth: String = "1 mm".into() => depth.to_string(), + depth: String = "1 mm".into() => shown_expr(depth, ValueKind::Length), deboss: bool = false => *deboss, } => json!({"depth": depth, "mode": if *deboss { "deboss" } else { "emboss" }}); ; custom { @@ -274,6 +274,8 @@ crate::dialog_descriptor::dialog_descriptors! { Align { flip: bool, components: bool, + /// Point-to-point: which of the world X, Y and Z axes take the offset. + axes: [bool; 3], }, /// Remove bodies from here on in the timeline. Remove, @@ -302,6 +304,8 @@ crate::dialog_descriptor::dialog_descriptors! { /// The measure chosen in the panel (centre, min or max); `None` follows the result's /// own default (centre where a round item gives one). show: Option, + /// Where the labels in the view were dragged to (#8510). + labels: crate::canvas::MeasureLabels, }, /// Move bodies by a distance along X, Y and Z. /// Move/Copy (see `dialogs_move`): the move type, X/Y/Z distances (Point to Position: the @@ -876,7 +880,7 @@ impl Dialog { "solid.align" => { let components = s.selection.iter().any(|x| matches!(x, Sel::Occurrence { .. })); Dialog::new( - Kind::Align { flip: false, components }, + Kind::Align { flip: false, components, axes: [true; 3] }, vec![ SelInput::new(if components { "Components" } else { "Bodies" }, BODIES, true), SelInput::new("From", FACES | EDGES | selection::VERTICES | PLANES | selection::ORIGIN, false), @@ -929,7 +933,7 @@ impl Dialog { vec![SelInput::new("Plane", PLANES | PLANAR_FACES, false)], ), "inspect.measure" => Dialog::new( - Kind::Measure { result: None, of: Vec::new(), show: None }, + Kind::Measure { result: None, of: Vec::new(), show: None, labels: Default::default() }, vec![SelInput::new( "Items", FACES @@ -1187,6 +1191,9 @@ impl Dialog { d.sync_thread(s); d.sync_material(s); d.advance(); + // Preselected items are already the dialog's inputs: the control reply and the first + // viewport paint must see the same measurement, without waiting for the panel to draw. + update_measure(app, &mut d); Some(d) } @@ -1602,6 +1609,9 @@ impl Dialog { /// the command starts. The island inside another profile's hole is left out, or it would fill /// the hole. False when the click is not one of those (the caller selects it as usual). pub fn pick_sketch(&mut self, s: &Session, id: u64) -> bool { + if self.refuse_sketch(s, id) { + return true; + } if self.active != 0 || !matches!(self.kind, Kind::Extrude { .. } | Kind::Revolve { .. } | Kind::Emboss { .. }) || !self.inputs.first().is_some_and(|i| i.accept & PROFILES != 0) @@ -1626,6 +1636,21 @@ impl Dialog { true } + /// A sketch clicked (in the view, the Browser or the History) while a pattern or Mirror waits + /// for its bodies or features: a sketch is neither, so the dialog says what to do instead of + /// taking nothing in silence (#10057). False when the click is not one of those. + pub fn refuse_sketch(&mut self, s: &Session, id: u64) -> bool { + if self.active != 0 + || !is_pattern(&self.kind) + || !self.inputs.first().is_some_and(|i| i.accept == BODIES || i.accept == selection::FEATURES) + || !s.doc.feature(id).is_some_and(|f| matches!(f.kind, FeatureKind::Sketch { .. })) + { + return false; + } + self.error = Some(SKETCH_NOT_OBJECT.to_string()); + true + } + /// A pick in the viewport (already accepted by the active input). pub fn pick(&mut self, s: &Session, sel: Sel) { if self.take_extrude_target(s, &sel) { @@ -2407,6 +2432,11 @@ fn objects_label(accept: Accept) -> &'static str { if accept == selection::FEATURES { "Features" } else { "Objects" } } +/// What a pattern or Mirror says when a sketch is clicked for its objects (#10057). It names what +/// to pick, so it shows as a hint rather than an error ([`missing_pick`]). +const SKETCH_NOT_OBJECT: &str = + "Pick bodies or features: a sketch is neither. Repeat its curves with the sketch's own Pattern or Mirror, or extrude it first."; + fn is_pattern(k: &Kind) -> bool { matches!(k, Kind::PatternRect { .. } | Kind::PatternCirc { .. } | Kind::PathPattern { .. } | Kind::Mirror) } @@ -2417,7 +2447,8 @@ fn object_type_row(d: &mut Dialog, ui: &mut egui::Ui) { let Some(inp) = d.inputs.first_mut() else { return }; let cur = usize::from(inp.accept == selection::FEATURES); let mut ty = cur; - choice_row(ui, "Object Type", "pat_obj", &mut ty, &["Bodies", "Features"]); + // What the two kinds are, and that a sketch is neither (#10057). + choice_row(ui, "Object Type", "pat_obj", &mut ty, &["Bodies", "Features"]).on_hover_text(crate::i18n::tr("ui.pattern_object_type_tip")); if ty != cur { inp.accept = if ty == 1 { selection::FEATURES } else { BODIES }; inp.label = objects_label(inp.accept); @@ -2640,6 +2671,14 @@ fn stored_revolve_axis(kind: &Kind) -> Option<&Value> { } } +/// Give the keyboard back from whatever text editor holds it, so the pick field clicked into +/// becomes the one field that looks active (#10066). +fn surrender_keyboard_focus(ui: &egui::Ui) { + if let Some(id) = ui.ctx().memory(|memory| memory.focused()) { + ui.ctx().memory_mut(|memory| memory.surrender_focus(id)); + } +} + pub(crate) fn input_rows_range(d: &mut Dialog, ui: &mut egui::Ui, s: &Session, names: crate::prefs::OriginPlaneNames, range: std::ops::Range) { // Edit Text shows its Path input among its own rows. if matches!(d.kind, Kind::Text(_)) { @@ -2649,12 +2688,15 @@ pub(crate) fn input_rows_range(d: &mut Dialog, ui: &mut egui::Ui, s: &Session, n let mut clear: Option = None; let mut activate: Option = None; let mut remove = None; + // Only one field may look active at a time (#10066): while a text editor holds the keyboard, + // no pick field draws its accent ring, and clicking a pick field takes the keyboard back. + let text_focus = crate::ui_kit::editor_has_focus(ui.ctx()); let shell_count = d.shell_faces(s).map(|faces| faces.len()); for (i, inp) in d.inputs.iter().enumerate().skip(range.start).take(range.end.saturating_sub(range.start)) { let stored_reference = feature_pattern_reference_key(d, i).and_then(|key| d.extra.get(key)).filter(|_| inp.items.is_empty()); form_row(ui, inp.label, |ui| { ui.horizontal(|ui| { - let active = i == d.active; + let active = i == d.active && !text_focus; // A chip: "↖ Select" (outlined) while empty, "N selected" (filled) once picked. let custom_axis = i == 1 && inp.items.is_empty() && stored_revolve_axis(&d.kind).is_some(); let filled = !inp.items.is_empty() || custom_axis || stored_reference.is_some(); @@ -2705,6 +2747,7 @@ pub(crate) fn input_rows_range(d: &mut Dialog, ui: &mut egui::Ui, s: &Session, n }; if chip.on_hover_text(tooltip).clicked() { activate = Some(i); + surrender_keyboard_focus(ui); } if !inp.items.is_empty() || stored_reference.is_some() { let rect = egui::Rect::from_center_size( @@ -2715,6 +2758,7 @@ pub(crate) fn input_rows_range(d: &mut Dialog, ui: &mut egui::Ui, s: &Session, n crate::scenario::publish_handle(&format!("dialog:clear:{}", inp.label), button.rect.center()); if button.clicked() { clear = Some(i); + surrender_keyboard_focus(ui); } } }); @@ -2916,9 +2960,11 @@ pub(crate) fn fill_cells(st: &solidcraft_engine::doc::ModelState, tools: &[Strin /// Measure again when the picked items changed. fn update_measure(app: &SolveApp, d: &mut Dialog) { let items: Vec = sels(d, 0).to_vec(); - if let Kind::Measure { result, of, .. } = &mut d.kind + if let Kind::Measure { result, of, labels, .. } = &mut d.kind && *of != items { + // Another measurement: its labels start in their own places. + *labels = Default::default(); *result = if items.is_empty() { None } else { Some(app.session.measure_items(&items)) }; *of = items; } @@ -3023,20 +3069,33 @@ fn auto_operation(app: &SolveApp, d: &mut Dialog) { smart.start = start; smart.latch = None; } + // Showing or hiding a body changes what the extrude meets: the latch was decided among the + // bodies shown before (#9919). + let shown = solidcraft_engine::shown_scope(&app.session); + if let Kind::Extrude { smart, .. } = &mut d.kind + && smart.shown != shown + { + smart.shown = shown.clone(); + smart.latch = None; + } // To an object, the extrude goes to it on whichever side it is: that is the side (#1633). if params.get("to").is_some() && let Some(reach) = solidcraft_engine::extrude_reach(&app.session, ¶ms) { side = crate::smart_extrude::side(reach, 0); } - // Hidden bodies are out of its scope (#6901). + // Hidden bodies are out of its scope (#6901). With none shown there is nothing to join or + // cut: an empty `participants` would read as every body, hidden ones too (#9919). let mut params = params; - if let Some(shown) = solidcraft_engine::shown_scope(&app.session) + let mut nothing_shown = false; + if let Some(shown) = shown && params.get("participants").is_none() { + nothing_shown = shown.is_empty(); params["participants"] = json!(shown); } - let Some(fresh) = solidcraft_engine::auto_operation(&app.session, ¶ms).and_then(|op| OPS.iter().position(|o| *o == op)) else { + let fresh = if nothing_shown { Some("new") } else { solidcraft_engine::auto_operation(&app.session, ¶ms) }; + let Some(fresh) = fresh.and_then(|op| OPS.iter().position(|o| *o == op)) else { return; }; if let Kind::Extrude { operation, smart, .. } = &mut d.kind { @@ -3057,10 +3116,28 @@ fn picked_side(app: &SolveApp, d: &Dialog, first: &Sel) -> Option { Some(crate::smart_extrude::picked_direction(crate::smart_extrude::view_ray(&app.cam, at), normal, false)) } -/// Show the sketch of the Extrude, Revolve, sheet metal base or contour flange, or Hole at sketch +/// Discard an editor whose target was removed, before its preview can draw again. Show the sketch +/// of the Extrude, Revolve, sheet metal base or contour flange, or Hole at sketch /// points the open dialog edits, and only while it is open: whichever way the dialog closed (OK, /// Cancel, another tool, another design), the sketch returns to its own visibility. -pub(crate) fn sync_feature_edit_sketch(app: &mut SolveApp) { +pub(crate) fn sync_feature_edit(app: &mut SolveApp) { + if app.dialog.as_ref().and_then(|d| d.editing).is_some_and(|(id, _)| app.session.doc.feature(id).is_none()) { + // Undo or deletion already chose the current document and history. Cancel's rollback + // would try to restore the obsolete editing session; just discard its work instead. + app.dialog = None; + app.applying = None; + if let Some(state) = app.editing_state.take() { + // Undo can save the editor's temporary rollback in Redo (deletion saves it in + // Undo). Those exact snapshots belong to the complete design, with its old marker. + for snapshot in app.session.undo.iter_mut().chain(&mut app.session.redo) { + if std::sync::Arc::ptr_eq(&snapshot.doc, &state.source.doc) { + snapshot.doc = state.original.doc.clone(); + } + } + } + app.preview.reset(); + app.op_check.abandon(); + } app.session.feature_edit_sketch = app.dialog.as_ref().and_then(|d| d.editing).and_then(|(id, _)| match &app.session.doc.feature(id)?.kind { FeatureKind::Extrude { sketch, .. } | FeatureKind::Revolve { sketch, .. } @@ -3116,7 +3193,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { // A click in the view's corner is for the dialog open this frame, or for none. let corner = app.viewport.corner.take(); app.viewport.corner_ok = false; - sync_feature_edit_sketch(app); + sync_feature_edit(app); app.op_check.poll(); if app.tree.redefine.is_none() { app.viewport.dialog_rect = None; @@ -3402,7 +3479,11 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { { plane["fixed"] = json!(true); let before = app.settled_cam(); - match app.run("sketch.create", json!({"plane":plane})) { + let mut params = json!({"plane":plane}); + if app.preferences.sketch_face_activates_part { + params["activate_part"] = json!(true); + } + match app.run("sketch.create", params) { Ok(_) => { app.look_at_opened_sketch(before); cancel = true; @@ -3813,7 +3894,10 @@ fn hole_defaults(s: &Session) -> Kind { pub fn start_sketch(app: &mut SolveApp, sel: &Sel) -> Result<(), String> { let mut params = match sel { Sel::Plane { name } => json!({ "plane": shown_origin_plane(app, name).unwrap_or_else(|| json!(name)) }), - Sel::Face { .. } => json!({ "plane": crate::sketch_attachment_ui::face_plane(app, sel).ok_or("Selected face placement no longer exists")? }), + Sel::Face { .. } => json!({ + "plane": crate::sketch_attachment_ui::face_plane(app, sel).ok_or("Selected face placement no longer exists")?, + "activate_part": app.preferences.sketch_face_activates_part, + }), _ => return Err("Select an origin plane, construction plane or planar body face".into()), }; if let Sel::Plane { name } = sel @@ -4642,7 +4726,7 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result, S need(0, "faces")?; ("face.delete", json!({"faces": face_points(0)})) } - Kind::Align { flip, components } => { + Kind::Align { flip, components, axes } => { need(0, if *components { "components" } else { "bodies" })?; need(1, "what to move from")?; need(2, "where to move it to")?; @@ -4653,7 +4737,11 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result, S } json!({"paths": paths, "flip": flip}) } else { - json!({"bodies": sels(d, 0).body_names(), "flip": flip}) + let mut p = json!({"bodies": sels(d, 0).body_names(), "flip": flip}); + if *axes != [true; 3] { + p["axes"] = json!(axes); + } + p }; for (i, key) in [(1, "from"), (2, "to")] { let snap = d.extra.get(&format!("{key}_snap")).and_then(Value::as_str).unwrap_or("picked"); @@ -5656,7 +5744,7 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option) -> Option serde_json::to_value(geo).ok(), _ => None, }; - d.kind = Kind::Revolve { angle: angle.to_string(), operation: op_index(operation), axis: stored_axis }; + d.kind = Kind::Revolve { angle: shown_expr(angle, ValueKind::Angle), operation: op_index(operation), axis: stored_axis }; d.extra.insert("convention".into(), json!(convention)); if let Some(a2) = angle2 { d.extra.insert("angle2".into(), json!(a2)); @@ -5695,17 +5783,17 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option) -> Option) -> Option) -> Option, &[&str])> = vec![ + (one("solid.fillet", json!({"edges": [[20, 0, 20]], "radius": 2})), &["2 mm"]), + (one("solid.chamfer", json!({"edges": [[20, 0, 20]], "distance": 1, "distance2": 1.5})), &["1 mm", "1.5 mm"]), + (one("solid.chamfer", json!({"edges": [[20, 0, 20]], "distance": 1, "angle": 30})), &["1 mm", "30 deg"]), + (one("solid.shell", json!({"faces": [[20, 15, 20]], "thickness": 2})), &["2 mm"]), + (one("solid.draft", json!({"faces": [[0, 15, 10]], "angle": 3, "neutral": "XY"})), &["3 deg"]), + (one("solid.offset_face", json!({"faces": [[20, 15, 20]], "distance": 2})), &["2 mm"]), + (one("solid.bounding_solid", json!({"margin": 2})), &["2 mm"]), + (one("construct.plane.offset", json!({"base": "XY", "offset": 5})), &["5 mm"]), + (profile.iter().cloned().chain([("solid.revolve", json!({"sketch": "Profile", "axis": "Z", "angle": 90}))]).collect(), &["90 deg"]), + ]; + for (cmds, expected) in cases { + let mut h = Harness::new(); + h.app.home.open = false; + h.app.run("solid.box", json!({"length": 40, "width": 30, "height": 20})).unwrap(); + let command = cmds.last().map(|(c, _)| *c).unwrap_or_default(); + for (c, p) in cmds { + h.app.run(c, p).unwrap(); + } + h.frames(2); + let id = h.app.session.doc.features.last().unwrap().id; + let count = h.app.session.doc.features.len(); + h.app.edit_feature(id); + h.frames(4); + let shown = match &h.app.dialog.as_ref().unwrap().kind { + Kind::Fillet { radius, ctype: 0, .. } => vec![radius.clone()], + Kind::Fillet { radius, distance2, ctype: 1, .. } => vec![radius.clone(), distance2.clone()], + Kind::Fillet { radius, angle, ctype: 2, .. } => vec![radius.clone(), angle.clone()], + Kind::Shell { thickness } => vec![thickness.clone()], + Kind::Draft { angle } => vec![angle.clone()], + Kind::OffsetFaces { distance } => vec![distance.clone()], + Kind::Stock { margin } => vec![margin.clone()], + Kind::OffsetPlane { offset } => vec![offset.clone()], + Kind::Revolve { angle, .. } => vec![angle.clone()], + other => panic!("{command}: {other:?}"), + }; + assert_eq!(shown, expected, "{command}"); + h.step(&json!({"widget": "OK"})).unwrap(); + h.frames(4); + assert!(h.app.dialog.is_none(), "{command}: OK applies the unchanged editor"); + assert_eq!(h.app.session.doc.features.len(), count, "{command}: the feature is redefined in place"); + } +} diff --git a/crates/ui-egui/src/dialogs_assembly.rs b/crates/ui-egui/src/dialogs_assembly.rs index 83c2926c69..845cbe9d7d 100644 --- a/crates/ui-egui/src/dialogs_assembly.rs +++ b/crates/ui-egui/src/dialogs_assembly.rs @@ -12,7 +12,7 @@ use serde_json::{Value, json}; use solidcraft_engine::Sel; use solidcraft_engine::Session; use solidcraft_engine::doc::expr::Kind as ValueKind; -use solidcraft_engine::doc::joints::JointKind; +use solidcraft_engine::doc::joints::{JointKind, JointOrigin, Snap}; use solidcraft_engine::geom::{Aabb3, Mesh, Vec3}; use crate::SolveApp; @@ -137,9 +137,15 @@ pub struct JointForm { /// Editing this joint (its origins stay; type, alignment and limits change). pub editing: Option, pub kind: usize, + /// Edit Joint: the sides as the dialog opened on them. A side still that pick is not sent, + /// so it keeps its origin exactly. + pub sides: [Option; 2], /// Flip B relative to the mate (faces apart instead of together). pub flip: bool, pub angle: String, + /// Edit Joint: the angle as the dialog opened on it. Sent only when changed, so a side moved + /// without it turns B as joint.create would. + pub angle_shown: String, pub offset: [String; 3], /// Per joint value: limited, minimum, maximum. pub limits: Vec<(bool, String, String)>, @@ -155,8 +161,10 @@ impl JointForm { as_built, editing: None, kind: 0, + sides: [None, None], flip: false, angle: "0 deg".into(), + angle_shown: "0 deg".into(), offset: std::array::from_fn(|_| crate::length_input::default(doc, "0")), limits: Vec::new(), animate: false, @@ -217,10 +225,7 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec)> { let s = &app.session; let (asm, inputs) = match id { "component.create" => (Asm::NewComponent { name: String::new(), activate: true }, vec![SelInput::new("Bodies (optional)", BODIES, true)]), - "joint.create" => ( - Asm::Joint(Box::new(JointForm::new(false, &app.session.doc))), - vec![SelInput::new("Component 1", SNAPS, false), SelInput::new("Component 2", SNAPS, false)], - ), + "joint.create" => (Asm::Joint(Box::new(JointForm::new(false, &app.session.doc))), side_inputs()), "joint.as_built" => ( Asm::Joint(Box::new(JointForm::new(true, &app.session.doc))), vec![SelInput::new("Components", BODIES, true), SelInput::new("Position", SNAPS, false)], @@ -321,16 +326,121 @@ pub(crate) fn num(v: f64, unit: &str) -> String { format!("{} {unit}", crate::dialogs_coupling::trim(v)) } -/// The Edit Joint dialog for a joint (by id). +/// The two side inputs of the Joint dialog (Joint and Edit Joint): the picks of each origin. +fn side_inputs() -> Vec { + vec![SelInput::new("Component 1", SNAPS, false), SelInput::new("Component 2", SNAPS, false)] +} + +/// The pick a joint side shows in the side inputs: the face or edge of its own occurrence that +/// the side's snap names (the piece nearest its pick, when the name has several), else the face +/// or edge nearest the pick. A point or frame side names no face: None. +fn side_pick(app: &SolveApp, o: &JointOrigin) -> Option { + let s = &app.session; + let (pick, face, edge) = match &o.snap { + Snap::FaceCenter { pick, face, .. } | Snap::CircleCenter { pick, face, .. } => (*pick, face.as_deref(), None), + Snap::Edge { pick, edge, .. } => (*pick, None, edge.as_deref()), + Snap::Point { .. } | Snap::Frame { .. } => return None, + }; + // The pick is in the component's coordinates; the placed bodies are in the world. + let at = + if o.occurrence == 0 { solidcraft_engine::doc::IDENTITY } else { solidcraft_engine::doc::joints::occurrence_world(&s.doc, o.occurrence) }; + let p = solidcraft_engine::doc::apply_point(&at, pick); + let st = s.world_state(); + let own: Vec<&solidcraft_engine::doc::ModelBody> = st.bodies.iter().filter(|b| occurrence_of_body(s, &b.name) == Some(o.occurrence)).collect(); + let named = match (face, edge) { + (Some(name), _) => own.iter().filter_map(|b| named_face(b, name, p)).min_by(|a, b| a.0.total_cmp(&b.0)), + (None, Some(name)) => own.iter().filter_map(|b| named_edge(b, name, p)).min_by(|a, b| a.0.total_cmp(&b.0)), + (None, None) => None, + }; + named.map(|(_, sel)| sel).or_else(|| nearest_pick(&own, p, matches!(o.snap, Snap::Edge { .. }))) +} + +/// The distance from `p` to the face `f` of `m`, measured to its triangles. +fn face_dist(m: &Mesh, f: usize, p: Vec3) -> Option { + m.triangles + .iter() + .zip(&m.tri_face) + .filter(|(_, tf)| **tf as usize == f) + .filter_map(|(t, _)| m.tri(t)) + .map(|[a, c, e]| crate::preview::point_triangle_dist(p, a, c, e)) + .reduce(f64::min) +} + +/// The distance from `p` to the mesh edge `i` of `m`. +fn edge_dist(m: &Mesh, i: usize, p: Vec3) -> Option { + m.edges.get(i)?.windows(2).map(|w| p.dist_to_segment(w[0], w[1])).reduce(f64::min) +} + +/// The face of `b` the name `name` names, the piece nearest `p` when it has several. +fn named_face(b: &solidcraft_engine::doc::ModelBody, name: &str, p: Vec3) -> Option<(f64, Sel)> { + let m = b.mesh(); + let names = solidcraft_engine::doc::naming::face_names(b); + names + .iter() + .enumerate() + .filter(|(_, n)| n.as_str() == name) + .filter_map(|(f, _)| face_dist(&m, f, p).map(|d| (d, Sel::Face { body: b.name.clone(), index: f, point: p }))) + .min_by(|a, b| a.0.total_cmp(&b.0)) +} + +/// The edge of `b` the name `name` names (a split edge: the piece nearest `p`). +fn named_edge(b: &solidcraft_engine::doc::ModelBody, name: &str, p: Vec3) -> Option<(f64, Sel)> { + let m = b.mesh(); + let names = solidcraft_engine::doc::naming::edge_names(b); + let index = match names.iter().position(|n| n == name) { + Some(i) => i, + None => { + let (at, _) = solidcraft_engine::doc::naming::find_edge(b, name, p).ok()?; + (0..m.edges.len()) + .min_by(|x, y| edge_dist(&m, *x, at).unwrap_or(f64::INFINITY).total_cmp(&edge_dist(&m, *y, at).unwrap_or(f64::INFINITY)))? + } + }; + let d = edge_dist(&m, index, p)?; + Some((d, Sel::Edge { body: b.name.clone(), index, point: p })) +} + +/// The face (or edge) of the bodies `own` nearest `p`. +fn nearest_pick(own: &[&solidcraft_engine::doc::ModelBody], p: Vec3, edges: bool) -> Option { + let mut best: Option<(f64, Sel)> = None; + for b in own { + let m = b.mesh(); + if edges { + for i in 0..m.edges.len() { + let Some(d) = edge_dist(&m, i, p) else { continue }; + if best.as_ref().is_none_or(|(bd, _)| d < *bd) { + best = Some((d, Sel::Edge { body: b.name.clone(), index: i, point: p })); + } + } + } else { + for (t, f) in m.triangles.iter().zip(&m.tri_face) { + let Some([a, c, e]) = m.tri(t) else { continue }; + let d = crate::preview::point_triangle_dist(p, a, c, e); + if best.as_ref().is_none_or(|(bd, _)| d < *bd) { + best = Some((d, Sel::Face { body: b.name.clone(), index: *f as usize, point: p })); + } + } + } + } + best.map(|(_, sel)| sel) +} + +/// The Edit Joint dialog for a joint (by id). Its two sides show as the Joint dialog's do, each +/// pre-filled with the pick it sits on. pub fn edit_joint(app: &SolveApp, id: u64) -> Option { let j = app.session.doc.assembly.joints.iter().find(|j| j.id == id)?; let mut f = JointForm::new(false, &app.session.doc); f.editing = Some(id); + f.sides = [side_pick(app, &j.a), side_pick(app, &j.b)]; + let mut inputs = side_inputs(); + for (input, pick) in inputs.iter_mut().zip(&f.sides) { + input.items = pick.iter().cloned().collect(); + } f.kind = TYPES.iter().position(|t| JointKind::parse(t) == Some(j.kind)).unwrap_or(0); f.flip = j.flip; // An offset or angle typed as an expression shows (and is sent back) as that expression, so // the joint keeps following its parameters. f.angle = j.exprs.angle.clone().unwrap_or_else(|| num(j.angle.to_degrees(), "deg")); + f.angle_shown = f.angle.clone(); f.offset[2] = j.exprs.offset.clone().unwrap_or_else(|| crate::length_input::model(&app.session.doc, j.offset)); f.fit_limits(&app.session.doc); for (i, l) in f.limits.iter_mut().enumerate() { @@ -339,7 +449,7 @@ pub fn edit_joint(app: &SolveApp, id: u64) -> Option { *l = (true, fmt(*lo), fmt(*hi)); } } - Some(Dialog::new(Kind::Assembly(Asm::Joint(Box::new(f))), vec![])) + Some(Dialog::new(Kind::Assembly(Asm::Joint(Box::new(f))), inputs)) } /// The Drive Joints dialog on a joint (by id). @@ -970,7 +1080,20 @@ pub fn commands(app: &SolveApp, k: &Asm, inputs: &[SelInput]) -> Result = f.limits.iter().take(jk.dofs().len()).map(|(on, lo, hi)| if *on { json!([lo, hi]) } else { Value::Null }).collect(); if let Some(id) = f.editing { - let mut out = cmd("joint.edit", json!({"joint": id, "type": kind, "flip": f.flip, "offset": f.offset[2], "angle": f.angle})); + let mut p = json!({"joint": id, "type": kind, "flip": f.flip, "offset": f.offset[2]}); + // The angle only when the user changed it: a side moved without it turns B as joint.create would. + if f.angle != f.angle_shown { + p["angle"] = json!(f.angle); + } + // A side picked again moves the joint's attachment there; one left as it was keeps its origin. + for (i, key) in ["a", "b"].into_iter().enumerate() { + let picked = sel_items(inputs, i).first(); + if picked != f.sides[i].as_ref() { + let what = if i == 0 { "component 1's joint origin" } else { "component 2's joint origin" }; + p[key] = snap(i, what)?.param; + } + } + let mut out = cmd("joint.edit", p); for (i, l) in limits.iter().enumerate() { let p = match l.as_array() { Some(a) => json!({"joint": id, "index": i, "min": a.first(), "max": a.get(1)}), @@ -1510,6 +1633,105 @@ pub fn pick_joint(app: &mut SolveApp, id: u64, add: bool) { } } +/// A placement's Joints folder (#9130): the design's joints that attach this placement (it is +/// one of the two they join), each beside the placement it joins this one to. The rows are the +/// same joints as the root Joints folder's and act the same: a click picks the joint, a +/// double-click edits it, the eye shows or hides its glyph and the menu is the joint's menu. +/// The folder starts closed and has no eye of its own (the root folder's eye hides every joint +/// glyph); it isn't drawn for a placement no joint attaches. `key` is the placement's row key. +pub fn component_joints(app: &mut SolveApp, ui: &mut egui::Ui, path: &[u64], key: &str, depth: usize) { + use crate::browser::{Row, draw_row, is_open, toggle}; + let joints = placement_joints(&app.session.doc, path); + if joints.is_empty() { + return; + } + let key = format!("{key}/joints"); + let open = is_open(app, &key, false); + let r = draw_row(ui, ui.id().with(&key), &Row { depth, fold: Some(open), icon: "folder", label: "Joints", ..Default::default() }); + if r.fold || r.clicked { + toggle(app, &key, false); + } + if !open { + return; + } + // Hiding the root folder hides every glyph: these rows fade with it, as its own rows do. + let folder_shown = !app.session.folder_hidden("c0/joints"); + let mut dialog: Option = None; + for j in joints { + let id = j.id; + let visible = app.session.joint_visible(id); + let selected = app.tree.picked_joints.contains(&id); + let r = draw_row( + ui, + ui.id().with((&key, "joint", id)), + &Row { + depth: depth + 1, + eye: Some(visible), + icon: "joint", + label: &j.name, + secondary: &j.other, + name: true, + selected, + dim: j.suppressed || !visible || !folder_shown, + ..Default::default() + }, + ); + if let Some(resp) = &r.resp { + resp.clone().on_hover_text(format!("{:?} joint", j.kind)); + } + if r.eye { + let _ = app.run("browser.visibility", json!({ "joints": [id], "visible": !visible })); + } else if r.clicked { + pick_joint(app, id, ui.input(|i| i.modifiers.command || i.modifiers.ctrl)); + } + if r.double { + dialog = edit_joint(app, id); + } + if let Some(at) = r.secondary { + if !app.tree.picked_joints.contains(&id) { + pick_joint(app, id, false); + } + crate::context_menu::open_for(app, at, crate::context_menu::Target::Joint { id }); + } + } + if let Some(d) = dialog { + app.tool = None; + app.dialog = Some(d); + } +} + +/// A joint as a placement's Joints folder lists it. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct PlacementJoint { + pub id: u64, + pub name: String, + pub kind: JointKind, + pub suppressed: bool, + /// The name of the placement the joint joins this one to (empty when there is none). + pub other: String, +} + +/// The joints that attach the placement at `path` (its last occurrence is one of the two a +/// joint joins), in the design's joint order. +pub(crate) fn placement_joints(doc: &solidcraft_engine::doc::Document, path: &[u64]) -> Vec { + let Some(&occ) = path.last() else { return Vec::new() }; + let name_of = |o: u64| doc.occurrences.iter().find(|x| x.id == o).map(|x| x.name.clone()).unwrap_or_default(); + doc.assembly + .joints + .iter() + .filter_map(|j| { + let other = if j.a.occurrence == occ { + j.b.occurrence + } else if j.b.occurrence == occ { + j.a.occurrence + } else { + return None; + }; + Some(PlacementJoint { id: j.id, name: j.name.clone(), kind: j.kind, suppressed: j.suppressed, other: name_of(other) }) + }) + .collect() +} + #[cfg(test)] #[path = "dialogs_assembly_tests.rs"] mod tests; diff --git a/crates/ui-egui/src/dialogs_assembly_tests.rs b/crates/ui-egui/src/dialogs_assembly_tests.rs index 12883f17d1..07feadc53a 100644 --- a/crates/ui-egui/src/dialogs_assembly_tests.rs +++ b/crates/ui-egui/src/dialogs_assembly_tests.rs @@ -215,7 +215,7 @@ fn edit_joint_runs_edit_and_limits() { .unwrap(); let id = app.session.doc.assembly.joints[0].id; let mut d = edit_joint(&app, id).unwrap(); - assert!(d.inputs.is_empty()); + assert_eq!(d.inputs.len(), 2, "the two sides show as in the Joint dialog"); if let Kind::Assembly(Asm::Joint(f)) = &mut d.kind { assert_eq!(TYPES[f.kind], "slider"); f.limits[0] = (true, "0 mm".into(), "15 mm".into()); @@ -281,6 +281,75 @@ fn edit_joint_keeps_an_offset_typed_as_an_expression() { assert_eq!((j.exprs.offset.as_deref(), j.exprs.angle.as_deref()), (Some("gap"), None)); } +/// Edit Joint shows both sides pre-filled, each on its own component. OK sends no side while +/// they are as they were; picking one side again sends only that side, and the joint keeps its +/// other side. +#[test] +fn edit_joint_shows_both_sides_and_a_repick_sends_only_that_side() { + let mut app = two_boxes(); + let (a, b) = (occ(&app, "A"), occ(&app, "B")); + app.run("joint.create", json!({"type": "rigid", "a": {"occurrence": a, "face": [10, 10, 10]}, "b": {"occurrence": b, "face": [40, 10, 0]}})) + .unwrap(); + let before = app.session.doc.assembly.joints[0].clone(); + let mut d = edit_joint(&app, before.id).unwrap(); + assert_eq!(d.inputs.len(), 2); + for (input, occurrence) in d.inputs.iter().zip([a, b]) { + let Some(Sel::Face { body, .. }) = input.items.first() else { panic!("no side pick: {:?}", input.items) }; + assert_eq!(occurrence_of_body(&app.session, body), Some(occurrence), "{body}"); + } + let c = apply_commands(&app, &d).unwrap(); + assert!(c[0].1.get("a").is_none() && c[0].1.get("b").is_none(), "{}", c[0].1); + // B's top face, where B now sits on A: side b alone is picked again, and the angle the + // dialog opened on is not sent (a side moved turns B as joint.create would). + d.active = 1; + d.pick(&app.session, face_sel(&app.session, Vec3::new(10.0, 10.0, 20.0)).unwrap()); + let c = apply_commands(&app, &d).unwrap(); + assert_eq!(c[0].0, "joint.edit"); + assert!(c[0].1.get("a").is_none() && c[0].1.get("angle").is_none(), "{}", c[0].1); + assert_eq!(c[0].1["b"]["occurrence"], json!(b)); + // An angle the user types is sent. + if let Kind::Assembly(Asm::Joint(f)) = &mut d.kind { + f.angle = "45 deg".into(); + } + let c = apply_commands(&app, &d).unwrap(); + assert_eq!(c[0].1["angle"], json!("45 deg"), "{}", c[0].1); + for (id, p) in c { + app.run(&id, p).unwrap(); + } + assert!((app.session.doc.assembly.joints[0].angle.to_degrees() - 45.0).abs() < 1e-9); + let after = app.session.doc.assembly.joints[0].clone(); + assert_eq!(after.a, before.a); + assert_eq!(after.b.occurrence, b); + assert_ne!(after.b, before.b); +} + +/// A side on a hole's wall shows the wall, a cylinder, not the plate's face beside the hole: the +/// face its snap names is the one the pre-fill takes, however near the other face's plane lies. +#[test] +fn a_hole_side_shows_its_wall_not_the_plate_beside_it() { + let mut s = Session::default(); + s.execute("component.create", &json!({"name": "Plate"})).unwrap(); + s.execute("solid.box", &json!({"length": 40, "width": 40, "height": 10, "corner": [0, 0, 0]})).unwrap(); + s.execute("solid.cylinder", &json!({"diameter": 10, "height": 20, "base": [20, 20, -5], "operation": "cut"})).unwrap(); + s.execute("component.create", &json!({"name": "Peg"})).unwrap(); + s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10, "corner": [60, 0, 0]})).unwrap(); + s.execute("component.activate", &json!({"component": "root"})).unwrap(); + let mut app = SolveApp::new(s, Services::default()); + let (plate, peg) = (occ(&app, "Plate"), occ(&app, "Peg")); + app.run("occurrence.ground", json!({"occurrence": plate, "grounded": true})).unwrap(); + app.run( + "joint.create", + json!({"type": "rigid", "a": {"occurrence": plate, "circle": [25, 20, 8]}, "b": {"occurrence": peg, "face": [65, 5, 0]}}), + ) + .unwrap(); + let joint = app.session.doc.assembly.joints[0].clone(); + assert!(matches!(joint.a.snap, solidcraft_engine::doc::joints::Snap::CircleCenter { face: Some(_), .. }), "{:?}", joint.a.snap); + let d = edit_joint(&app, joint.id).unwrap(); + let item = d.inputs[0].items.first().unwrap_or_else(|| panic!("no pre-fill for the hole side")); + let snap = snap_of(&app.session, item).unwrap(); + assert_eq!(snap.kind, SnapKind::CircleCenter, "the pre-fill is the plate's face, not the hole's wall: {item:?}"); +} + #[test] fn as_built_animation_drives_the_new_shared_id_and_handles_exhaustion() { let mut app = two_boxes(); diff --git a/crates/ui-egui/src/dialogs_dense.rs b/crates/ui-egui/src/dialogs_dense.rs index c404672e9a..9262a62024 100644 --- a/crates/ui-egui/src/dialogs_dense.rs +++ b/crates/ui-egui/src/dialogs_dense.rs @@ -769,7 +769,7 @@ fn additional_rows(app: &mut SolveApp, d: &mut Dialog, ui: &mut egui::Ui) -> boo }); ui.add(egui::Label::new(crate::i18n::tr("ui.emboss_wrap_note")).wrap()); } - Kind::Align { flip, components } => { + Kind::Align { flip, components, axes: [ax, ay, az] } => { // Where on the picked face or edge to snap: as picked (a face turns to meet // the other, an edge at its middle), its centre, or a round one's centre. for (label, key) in [("From Snap", "from_snap"), ("To Snap", "to_snap")] { @@ -777,7 +777,11 @@ fn additional_rows(app: &mut SolveApp, d: &mut Dialog, ui: &mut egui::Ui) -> boo extra_choice(ui, &mut d.extra, key, key, &[("As Picked", "picked"), ("Center", "center"), ("Circle Center", "circle")]); }); } - ui_kit::options(ui, "feature_options", &mut [("Flip", flip)]); + if *components { + ui_kit::options(ui, "feature_options", &mut [("Flip", flip)]); + } else { + ui_kit::options(ui, "feature_options", &mut [("Flip", flip), ("X", ax), ("Y", ay), ("Z", az)]); + } // A part inside a subassembly placed more than once sits where that shared // definition says: aligning it moves it in every placement. if *components && align_moves_shared(&app.session, d.inputs.first().map_or(&[], |i| i.items.as_slice())) { diff --git a/crates/ui-egui/src/dialogs_move.rs b/crates/ui-egui/src/dialogs_move.rs index e243e11d92..87a3904e2f 100644 --- a/crates/ui-egui/src/dialogs_move.rs +++ b/crates/ui-egui/src/dialogs_move.rs @@ -506,8 +506,25 @@ pub fn drag(app: &SolveApp, d: &mut Dialog, g: Drag) { } } +/// The Free Move angles (X, Y, Z) that make the same turn as a turn of `angle` about `axis`, +/// in the dialog's order (Z applied last, then Y, then X — the same composition `step` uses), +/// so an edited move shows angles that re-make the turn the feature stored. +pub(crate) fn euler_from_axis_angle(axis: Vec3, angle: f64) -> (f64, f64, f64) { + let axis = axis.normalized().unwrap_or(Vec3::Z); + let m = gizmo::rotation(axis, angle); + // R = Rz(rz) * Ry(ry) * Rx(rx): row 2 is (-sin ry, cos ry sin rx, cos ry cos rx). + let ry = (-m[2][0]).clamp(-1.0, 1.0).asin(); + if m[2][1].abs() + m[2][2].abs() < 1e-9 { + // Half a turn about X or Y: only rx + rz shows; keep rz at zero. + ((-m[1][2]).atan2(m[1][1]), ry, 0.0) + } else { + (m[2][1].atan2(m[2][2]), ry, m[1][0].atan2(m[0][0])) + } +} + /// The dialog for editing a move: Free Move with its translation and, about a world axis -/// through the origin, its angle (another axis is kept as it is). +/// through the origin, its angle (another axis is shown as the Free Move angles that make +/// the same turn, or kept as it is when the angle does not evaluate to a number). pub fn for_feature( doc: &solidcraft_engine::doc::Document, d: &mut Dialog, @@ -528,10 +545,16 @@ pub fn for_feature( Some(0) => *rx = a.to_string(), Some(1) => *ry = a.to_string(), Some(_) => *rz = a.to_string(), - None => { - d.extra.insert("axis".into(), json!([dir.x, dir.y, dir.z])); - d.extra.insert("angle".into(), json!(a)); - } + None => match doc.eval(a, ValueKind::Angle) { + Ok(th) if th.is_finite() => { + let (ax, ay, az) = euler_from_axis_angle(dir, th); + (*rx, *ry, *rz) = (deg(ax), deg(ay), deg(az)); + } + _ => { + d.extra.insert("axis".into(), json!([dir.x, dir.y, dir.z])); + d.extra.insert("angle".into(), json!(a)); + } + }, } // The stored move turns about the origin. d.extra.insert("pivot".into(), json!([0.0, 0.0, 0.0])); diff --git a/crates/ui-egui/src/dialogs_move_tests.rs b/crates/ui-egui/src/dialogs_move_tests.rs index e2e0119fbf..1657d64c25 100644 --- a/crates/ui-egui/src/dialogs_move_tests.rs +++ b/crates/ui-egui/src/dialogs_move_tests.rs @@ -540,3 +540,106 @@ fn a_pick_on_the_moved_body_is_taken_back_by_the_current_move() { let elsewhere = Sel::Vertex { body: "Other".into(), point: Vec3::new(30.0, 30.0, 10.0) }; assert_eq!(from_shown(&h.app, elsewhere.clone()), elsewhere, "a pick on another body stays where it was made"); } + +/// The prefilled Free Move angles must re-make the turn the feature stored, whatever the axis: +/// recomposing them the way `step` does (Z, then Y, then X) gives the same rotation matrix. +#[test] +fn the_prefilled_angles_remake_the_stored_turn() { + let cases = [ + // #9696: the turn a Free Move of 10 deg on each axis stores. + (Vec3::new(0.5407162030609368, 0.6444004775717719, 0.5407162030609368), 16.7865_f64.to_radians()), + (Vec3::X, std::f64::consts::PI), + (Vec3::Y, std::f64::consts::FRAC_PI_2), + (Vec3::new(0.0, 0.0, -1.0), 0.5), + (Vec3::new(1.0, 2.0, 3.0).normalized().unwrap(), 0.7), + (Vec3::new(-0.3, 0.8, -0.5).normalized().unwrap(), -1.2), + ]; + for (axis, th) in cases { + let (rx, ry, rz) = euler_from_axis_angle(axis, th); + let r = gizmo::mul(&gizmo::mul(&gizmo::rotation(Vec3::Z, rz), &gizmo::rotation(Vec3::Y, ry)), &gizmo::rotation(Vec3::X, rx)); + let o = gizmo::rotation(axis, th); + for i in 0..3 { + for j in 0..3 { + assert!((r[i][j] - o[i][j]).abs() < 1e-9, "axis {axis:?} angle {th}: {r:?} != {o:?}"); + } + } + } +} + +/// The #9696 repro: a Free Move of 10 mm and 10 deg on each axis stores one turn about a +/// slanted axis; editing shows the stored translation and angles that make that turn again, +/// re-saving unchanged does not move the body, and zeroing every value brings it back. +#[test] +fn editing_a_free_move_shows_the_values_it_was_made_with() { + let mut h = crate::scenario::Harness::new(); + h.app.run("solid.box", json!({"length": 20, "width": 20, "height": 20})).unwrap(); + let id = h + .app + .run( + "solid.move", + json!({ + "bodies": ["Body1"], + "translate": ["9.729024 mm", "9.95222 mm", "10.327918 mm"], + "axis": [0.5407162030609368, 0.6444004775717719, 0.5407162030609368], + "angle": "16.7865 deg", + }), + ) + .unwrap()["feature"] + .as_u64() + .unwrap(); + let bounds = |h: &crate::scenario::Harness| h.app.session.world_state().body("Body1").unwrap().mesh().bounds(); + let before = bounds(&h); + let d = crate::dialogs::for_feature(&h.app, id, None).unwrap(); + let Kind::Move { x, y, z, rx, ry, rz, .. } = &d.kind else { panic!("not a move") }; + let shown = [len(&h.app, x).unwrap(), len(&h.app, y).unwrap(), len(&h.app, z).unwrap()]; + let want = [9.729024, 9.95222, 10.327918]; + assert!((Vec3::new(shown[0], shown[1], shown[2]) - Vec3::new(want[0], want[1], want[2])).len() < 1e-6, "translation {shown:?}"); + let angles = [ang(&h.app, rx).unwrap().to_degrees(), ang(&h.app, ry).unwrap().to_degrees(), ang(&h.app, rz).unwrap().to_degrees()]; + for a in angles { + assert!((a - 10.0).abs() < 1e-3, "the stored turn shows as the angles it was made with, not {angles:?}"); + } + assert!(d.extra.get("axis").is_none() && d.extra.get("angle").is_none(), "the turn is shown, not hidden in extra"); + // Re-saving the dialog unchanged must not move the body. + for (command, params) in crate::dialogs::apply_commands(&h.app, &d).unwrap() { + h.app.run(&command, params).unwrap(); + } + let after = bounds(&h); + assert!( + (after.min - before.min).len() < 1e-4 && (after.max - before.max).len() < 1e-4, + "re-saving unchanged must not move: {before:?} -> {after:?}" + ); + // Zeroing every value brings the body back where it was before the move. + let mut d = crate::dialogs::for_feature(&h.app, id, None).unwrap(); + if let Kind::Move { x, y, z, rx, ry, rz, .. } = &mut d.kind { + *x = "0".into(); + *y = "0".into(); + *z = "0".into(); + *rx = "0 deg".into(); + *ry = "0 deg".into(); + *rz = "0 deg".into(); + } + for (command, params) in crate::dialogs::apply_commands(&h.app, &d).unwrap() { + h.app.run(&command, params).unwrap(); + } + let back = bounds(&h); + assert!( + (back.min - Vec3::ZERO).len() < 1e-6 && (back.max - Vec3::new(20.0, 20.0, 20.0)).len() < 1e-6, + "zeroed values must undo the move: {back:?}" + ); +} + +/// A move about a world axis keeps its angle expression in that axis's field. +#[test] +fn editing_a_move_about_a_world_axis_keeps_the_angle_expression() { + let mut h = crate::scenario::Harness::new(); + h.app.run("solid.box", json!({"length": 20, "width": 20, "height": 20})).unwrap(); + let id = h + .app + .run("solid.move", json!({"bodies": ["Body1"], "translate": ["0", "0", "5 mm"], "axis": [0.0, 0.0, 1.0], "angle": "45 deg"})) + .unwrap()["feature"] + .as_u64() + .unwrap(); + let d = crate::dialogs::for_feature(&h.app, id, None).unwrap(); + let Kind::Move { rx, ry, rz, .. } = &d.kind else { panic!("not a move") }; + assert_eq!((rx.as_str(), ry.as_str(), rz.as_str()), ("0 deg", "0 deg", "45 deg")); +} diff --git a/crates/ui-egui/src/dialogs_sketch_tests.rs b/crates/ui-egui/src/dialogs_sketch_tests.rs index 1fea8292d9..fb9ad5766f 100644 --- a/crates/ui-egui/src/dialogs_sketch_tests.rs +++ b/crates/ui-egui/src/dialogs_sketch_tests.rs @@ -127,6 +127,51 @@ fn equation_curve_window_draws_and_reopens_on_its_formulas() { assert_eq!(sk.equation_of(&eq.curve).map(|e| e.end.as_str()), Some("20")); } +/// The window reaches the variable's unit (#9333): its checkbox draws the issue's curve, the +/// window reopens with it ticked, and unticking it reaches the curve too. +#[test] +fn equation_curve_window_gives_the_variable_its_ranges_unit() { + use solidcraft_engine::sketch::VariableUnit; + let mut h = fixture(); + h.app.session.execute("parameters.add", &json!({"name": "a", "expression": "10 mm"})).unwrap(); + h.app.start("sketch.equation_curve"); + h.settle(); + let Some(Kind::SketchModify(Mod::Equation { x, y, start, end, range_unit, .. })) = h.app.dialog.as_mut().map(|d| &mut d.kind) else { + panic!("no equation window") + }; + assert!(!*range_unit, "off to begin with"); + *x = "t".into(); + *y = "a ^ 2 / (4 * t)".into(); + *start = "a".into(); + *end = "3 * a".into(); + h.settle(); + let handle = format!("dialog:{}", crate::i18n::tr("ui.equation_variable_unit")); + h.step(&json!({"click": {"handle": handle}})).unwrap(); + h.settle(); + let Some(Kind::SketchModify(Mod::Equation { range_unit, .. })) = h.app.dialog.as_ref().map(|d| &d.kind) else { panic!("no equation window") }; + assert!(*range_unit, "the click ticks it"); + h.step(&json!({"click":{"handle":"dialog:ok"}})).unwrap(); + h.settle(); + assert!(h.app.dialog.is_none(), "the curve is made"); + let sid = h.app.session.active_sketch.unwrap(); + let eq = h.app.session.doc.sketch(sid).unwrap().equations.first().cloned().expect("an equation curve"); + assert_eq!(eq.variable_unit, VariableUnit::Range); + assert!(crate::sketch_modify_ui::edit_equation(&mut h.app, &eq.curve)); + h.settle(); + let Some(Kind::SketchModify(Mod::Equation { y, start, end, range_unit, .. })) = h.app.dialog.as_mut().map(|d| &mut d.kind) else { + panic!("no equation window") + }; + assert!(*range_unit, "reopens ticked"); + *y = "t / 2".into(); + *start = "0".into(); + *end = "10".into(); + *range_unit = false; + h.settle(); + h.step(&json!({"click":{"handle":"dialog:ok"}})).unwrap(); + h.settle(); + assert_eq!(h.app.session.doc.sketch(sid).unwrap().equation_of(&eq.curve).map(|e| e.variable_unit), Some(VariableUnit::Number)); +} + #[test] fn create_sketch_uses_card_and_still_starts_immediately_on_plane_pick() { let mut h = Harness::new(); @@ -265,6 +310,25 @@ fn apply_in_the_header_row_commits_once_and_keeps_the_dialog() { assert_eq!(scenario::rect_of("dialog:apply"), Some(apply), "the buttons stay where they were"); } +/// Interface › Dialog buttons › Top (#9860): the sketch panel's Finish Sketch row sits above its +/// options, and below them with the preference off. +#[test] +fn sketch_panel_finish_row_follows_dialog_buttons() { + for (placement, on_top) in [("top", true), ("bottom", false)] { + let mut h = fixture(); + h.call("ui.prefs", json!({"dialog_buttons": placement})); + h.settle(); + let finish = scenario::rect_of("sketch_palette:finish").unwrap_or_else(|| panic!("{placement}: no Finish Sketch")); + let first = scenario::rect_of("sketch_palette:toggle:show_constraints").unwrap_or_else(|| panic!("{placement}: no first option")); + if on_top { + assert!(finish.bottom() <= first.top(), "top: Finish {finish:?} above its first option {first:?}"); + } else { + assert!(first.bottom() <= finish.top(), "bottom: Finish {finish:?} below its first option {first:?}"); + } + assert!(scenario::handle_at("sketch_palette:finish").is_some(), "{placement}: the handle stays"); + } +} + #[test] fn picked_list_removes_one_item_and_field_still_activates_input() { let mut h = fixture(); diff --git a/crates/ui-egui/src/dim_view.rs b/crates/ui-egui/src/dim_view.rs index 93678d0204..7494c1380f 100644 --- a/crates/ui-egui/src/dim_view.rs +++ b/crates/ui-egui/src/dim_view.rs @@ -8,9 +8,10 @@ //! its dimension line), a double-click (or one click with the Dimension tool) edits its value //! in place with parameter completion. A finished sketch's shown dimensions (Show Dimensions, //! drawn by `crate::browser::dims_overlay`) take the same double-click from the model view: the -//! value is edited without editing the sketch. +//! value is edited without editing the sketch. A driven dimension that nothing else fixes any +//! more opens at its measured value too, and the value makes it driving. //! Everything acts through commands: `sketch.dimension_text`, `sketch.delete`, -//! `parameters.change`. +//! `sketch.toggle_driven`, `parameters.change`. use crate::ui_kit::{Container, Surface}; use std::cell::RefCell; @@ -62,6 +63,9 @@ struct Edit { /// A shown dimension of a finished sketch, edited from the model view without editing the /// sketch (its id). None: a dimension of the sketch being edited. sketch: Option, + /// A driven dimension that nothing else fixes any more, which the value makes driving (its + /// constraint id; `param` names the parameter it gets). + driven: Option, } #[derive(Clone, Copy, Debug)] @@ -154,8 +158,19 @@ pub fn begin_dimension(app: &mut SolveApp, entities: Vec, at: Vec2) -> R return Ok(()); } EDIT.with(|edit| { - *edit.borrow_mut() = - Some(Edit { param, expr, at: None, fresh: FRESH, wait: 10, draft: Some(draft), live: None, selection: None, picked: None, sketch: None }) + *edit.borrow_mut() = Some(Edit { + param, + expr, + at: None, + fresh: FRESH, + wait: 10, + draft: Some(draft), + live: None, + selection: None, + picked: None, + sketch: None, + driven: None, + }) }); PLACEMENT.with(|preview| *preview.borrow_mut() = None); Ok(()) @@ -175,8 +190,19 @@ pub fn begin_live(app: &mut SolveApp, entities: Vec, pointer: Option bool { selection: None, picked: None, sketch: Some(d.sketch), + driven: None, }) }); true @@ -477,11 +504,19 @@ fn open_editor(app: &mut SolveApp, id: Option) -> bool { let Some(d) = id.and_then(|id| DRAWN.with(|d| d.borrow().iter().find(|x| x.id == id).cloned())) else { return false; }; - let Some(param) = d.param else { - app.set_status("A driven dimension measures the sketch; it has no value to edit", false); - return true; + let (param, expr, driven) = match d.param { + Some(param) => { + let expr = app.session.doc.param(¶m).map(|p| p.expr.clone()).unwrap_or_default(); + (param, expr, None) + } + None => match freed(app, &d.id) { + Some((param, expr)) => (param, expr, Some(d.id.clone())), + None => { + app.set_status("A driven dimension measures the sketch; it has no value to edit", false); + return true; + } + }, }; - let expr = app.session.doc.param(¶m).map(|p| p.expr.clone()).unwrap_or_default(); EDIT.with(|e| { *e.borrow_mut() = Some(Edit { param, @@ -494,11 +529,63 @@ fn open_editor(app: &mut SolveApp, id: Option) -> bool { selection: None, picked: None, sketch: None, + driven, }) }); true } +/// A driven dimension that nothing else fixes any more, such as one placed while a Tangent +/// held its circle and left driven when the Tangent was deleted (#10018): the parameter it gets +/// as a driving one and its measured value, from making it driving on a scratch copy. None +/// while something still fixes what it measures (it can only measure). +fn freed(app: &SolveApp, constraint: &str) -> Option<(String, String)> { + let sketch = app.session.active_sketch?; + let mut scratch = app.session.scratch(); + scratch.execute("sketch.toggle_driven", &json!({"constraint": constraint, "driven": false})).ok()?; + let param = scratch.doc.sketch(sketch).ok()?.constraints.iter().find(|c| c.id == constraint)?.param.clone()?; + let expr = scratch.doc.param(¶m)?.expr.clone(); + Some((param, expr)) +} + +/// The value typed for a driven dimension (`freed`): it becomes driving at its measured value +/// (`sketch.toggle_driven`), then takes the value (`parameters.change`), in one undo step. When +/// the value is refused the design goes back to the checkpoint taken first, so it stays driven. +fn make_driving(app: &mut SolveApp, constraint: &str, typed: &str) -> Result { + let depth = app.session.undo.len(); + let checkpoint = app.run("edit.checkpoint", json!({}))?["checkpoint"].clone(); + let result = app + .run("sketch.toggle_driven", json!({"constraint": constraint, "driven": false})) + .map_err(|err| shown_error("sketch.toggle_driven", &err)) + .and_then(|_| { + let param = app + .session + .active_sketch + .and_then(|sketch| app.session.doc.sketch(sketch).ok()?.constraints.iter().find(|c| c.id == constraint)?.param.clone()) + .ok_or("The dimension value is unavailable")?; + change_value(app, ¶m, typed) + }); + let forget = result.is_ok(); + let _ = app.run("edit.restore_checkpoint", json!({"checkpoint": checkpoint, "forget": forget})); + if forget { + app.session.undo.truncate(depth + 1); + } + result +} + +/// Set dimension parameter `param` to the expression typed for it, in the parameter's unit. +fn change_value(app: &mut SolveApp, param: &str, typed: &str) -> Result { + let kind = app + .session + .doc + .param(param) + .and_then(|p| solidcraft_engine::doc::expr::unit_info(&p.unit)) + .map(|(k, _)| k) + .unwrap_or(solidcraft_engine::doc::expr::Kind::Unitless); + let expression = crate::length_input::expression(&app.session.doc, typed, kind); + app.run("parameters.change", json!({"name": param, "expression": expression})).map_err(|err| shown_error("parameters.change", &err)) +} + /// Start dragging the text of the dimension under `press` (true: a dimension took the drag). /// Its lines take it only off sketch geometry (`on_geometry`: a point or curve is under `press`). pub fn drag_start(app: &SolveApp, press: Pos2, press_sketch: Vec2, on_geometry: bool) -> bool { @@ -778,6 +865,7 @@ pub fn edit_param(app: &SolveApp, param: &str) { selection: None, picked: None, sketch: None, + driven: None, }) }); } @@ -946,16 +1034,10 @@ fn edit_box(app: &mut SolveApp, ctx: &egui::Context) { } else { Err("The sketch changed; place the dimension again".into()) } + } else if let Some(constraint) = &e.driven { + make_driving(app, constraint, &e.expr) } else { - let kind = app - .session - .doc - .param(&e.param) - .and_then(|p| solidcraft_engine::doc::expr::unit_info(&p.unit)) - .map(|(k, _)| k) - .unwrap_or(solidcraft_engine::doc::expr::Kind::Unitless); - let expression = crate::length_input::expression(&app.session.doc, &e.expr, kind); - app.run("parameters.change", json!({"name": e.param, "expression": expression})).map_err(|err| shown_error("parameters.change", &err)) + change_value(app, &e.param, &e.expr) }; match result { Err(err) => { diff --git a/crates/ui-egui/src/dimension_create_ui_tests.rs b/crates/ui-egui/src/dimension_create_ui_tests.rs index 7484dd234f..310420f979 100644 --- a/crates/ui-egui/src/dimension_create_ui_tests.rs +++ b/crates/ui-egui/src/dimension_create_ui_tests.rs @@ -574,6 +574,82 @@ fn an_over_constraining_dimension_is_placed_as_a_driven_one() { assert_eq!(h.app.session.doc.to_json(), original); } +/// A dimension placed while a constraint fixed what it measures is driven; once that constraint +/// is deleted, a double-click opens its value, and the value makes it driving, in one undo step +/// (#10018). While the constraint holds it stays read-only, and a value refused leaves it driven. +#[test] +fn a_driven_dimension_nothing_fixes_any_more_takes_a_value() { + let mut h = line([20., 0.]); + h.app.run("sketch.dimension", json!({"entities":["l1"],"value":"20 mm","text_at":[10,6]})).unwrap(); + h.app.run("sketch.circle.center", json!({"center":[0,0],"radius":15})).unwrap(); + // The circle's centre is on the origin and the line's end on the circle: its size is fixed. + let coincident = h.app.run("sketch.constraint.coincident", json!({"a":"l1.end","b":"c1"})).unwrap()["constraint"].as_str().unwrap().to_owned(); + h.app.run("sketch.dimension", json!({"entities":["c1"],"driven":true,"text_at":[-14,14]})).unwrap(); + h.settle(); + let sketch = h.app.session.active_sketch.unwrap(); + let dim = |h: &Harness| { + let state = h.app.session.world_state(); + let sk = &state.sketch(sketch).unwrap().sketch; + sk.constraints + .iter() + .find(|c| { + matches!( + c.kind, + solidcraft_engine::sketch::ConstraintKind::Diameter { .. } | solidcraft_engine::sketch::ConstraintKind::Radius { .. } + ) + }) + .cloned() + .unwrap() + }; + let id = dim(&h).id; + assert!(dim(&h).driven && dim(&h).param.is_none(), "{:?}", dim(&h)); + let double_click = |h: &mut Harness| { + h.frames(40); + h.step(&json!({"click":{"dimension":id},"double":true})).unwrap(); + }; + + // The Coincident still fixes the circle: the dimension only measures. + let fixed = h.app.session.doc.to_json(); + double_click(&mut h); + assert!(!crate::dim_view::editing(), "a dimension the sketch determines has no value to edit"); + assert!(h.app.status.as_ref().is_some_and(|s| s.0.contains("driven")), "{:?}", h.app.status); + assert_eq!(h.app.session.doc.to_json(), fixed); + + // Without it, the value opens at what the dimension measures, named as the parameter it gets. + h.app.run("sketch.delete", json!({"entities":[coincident]})).unwrap(); + h.settle(); + let before = h.app.session.doc.to_json(); + let undo = h.app.session.undo.len(); + let name = h.app.session.doc.next_d_name(); + let measured = dim(&h).kind.value().unwrap(); + double_click(&mut h); + assert!(crate::dim_view::editing(), "{:?}", h.app.status); + assert!(h.widget(&format!("Dimension: {name}")).is_some(), "{}", h.call("ui.inspect", json!({}))); + + // A value the design refuses leaves it driven, and the design and its history as they were. + type_value(&mut h, "nosuch * 2"); + h.step(&json!({"key":"Enter"})).unwrap(); + assert!(crate::dim_view::editing(), "a refused value keeps the box open"); + assert_eq!(h.app.session.doc.to_json(), before); + assert_eq!(h.app.session.undo.len(), undo); + + // A value it takes makes it driving at that value. + type_value(&mut h, "30"); + h.step(&json!({"key":"Enter"})).unwrap(); + assert!(!crate::dim_view::editing(), "{:?}", h.app.status); + let driving = dim(&h); + assert!(!driving.driven && driving.param.as_deref() == Some(name.as_str()), "{driving:?}"); + let value = driving.kind.value().unwrap(); + assert!((value - 30.0).abs() < 1e-6 && (measured - 30.0).abs() > 1.0, "{measured} → {value}"); + assert!(h.app.session.world_state().sketch(sketch).unwrap().report.ok()); + + // One undo step puts the driven dimension back. + assert_eq!(h.app.session.undo.len(), undo + 1); + h.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(h.app.session.doc.to_json(), before); + crate::dim_view::cancel(); +} + // ---- Type-first: D, click, type, Enter ---- /// A sketch with `setup` drawn, the Top view centred on `centre`, and the Dimension tool on. diff --git a/crates/ui-egui/src/documents.rs b/crates/ui-egui/src/documents.rs index 60c4d7f13a..22562c4d2c 100644 --- a/crates/ui-egui/src/documents.rs +++ b/crates/ui-egui/src/documents.rs @@ -34,6 +34,66 @@ pub struct DocSlot { /// Its linked design files as the design last read them (`Documents::link_stamps`). link_stamps: Vec<(u64, crate::linked_source::Stamp)>, edit_in_place: Option, + /// The dialog or tool it had open and its toolbar tab, kept while another design is shown. + work: DocWork, +} + +/// What a design was doing when its tab was left, with the "Keep dialogs and tools open when +/// switching designs" preference (`keep_work_on_tab_switch`, #9360): the open dialog with its +/// picks and values, the tool, and the toolbar tab. They act on that design's session, which +/// waits in the same slot, so showing the tab again picks up exactly where it was left. +#[derive(Default)] +struct DocWork { + dialog: Option, + editing_state: Option, + tool: Option, + /// The toolbar tab shown, its contextual tab and the tab a sketch returns to (`None`: the + /// design was left without the preference, and comes back on MODEL as before). + tab: Option<(String, Option, Option)>, +} + +/// Take the active design's dialog, tool and toolbar tab, to keep with its tab. A feature still +/// computing after OK can't wait (its result belongs to the window thread): that dialog is +/// cancelled, as without the preference. +fn take_work(app: &mut SolveApp) -> DocWork { + if !app.preferences.keep_work_on_tab_switch || app.applying.is_some() { + crate::dialogs::cancel(app); + app.tool = None; + return DocWork::default(); + } + DocWork { + dialog: app.dialog.take(), + editing_state: app.editing_state.take(), + tool: app.tool.take(), + tab: Some((app.ui.tab.clone(), app.ui.ribbon_context.clone(), app.ui.sketch_return_tab.clone())), + } +} + +/// Give the incoming design back what [`take_work`] kept of it, after its session is in the app. +/// Kept work comes back even when the preference was turned off since: the dialog may hold the +/// design's history rolled back for an edit, and only the dialog's Cancel or OK puts it back. +/// `before`: the session's revision before the switch moved it on to refresh the views. +fn put_work(app: &mut SolveApp, mut work: DocWork, before: u64) { + // That refresh is not a change to the design: the edit's Cancel must still see the design + // it left (`Session::restore_editor` compares revisions), as `PendingFeatureEdit` does. + if let Some(state) = &mut work.editing_state { + state.source.revision = state.source.revision.saturating_add(app.session.revision.saturating_sub(before)); + } + app.dialog = work.dialog; + app.editing_state = work.editing_state; + app.tool = work.tool; + match work.tab { + Some((tab, context, back)) => { + app.ui.tab = tab; + app.ui.ribbon_context = context; + app.ui.sketch_return_tab = back; + } + None => { + app.ui.ribbon_context = None; + app.ui.tab = "MODEL".into(); + } + } + crate::workspace::sync_context(app); } /// The per-design part of the UI state the session doesn't keep (what is hidden, shown or locked @@ -67,6 +127,7 @@ impl DocSlot { agent_chat: Default::default(), link_stamps: Vec::new(), edit_in_place: None, + work: DocWork::default(), } } } @@ -138,12 +199,12 @@ fn swap_in(app: &mut SolveApp, mut slot: DocSlot) -> DocSlot { if crate::render_workspace::active(app) { let _ = crate::render_workspace::enter(app, false); } - // Whatever was going on in the design leaves with it. + // Whatever was going on in the design leaves with it: cancelled, or kept with its tab + // (`take_work`). crate::timeline::stop_playback(app); crate::sketch_text_move::cancel_drag(app); - crate::dialogs::cancel(app); + let work = take_work(app); crate::dim_view::cancel(); - app.tool = None; app.params = None; app.design_properties = None; app.bom = None; @@ -170,6 +231,7 @@ fn swap_in(app: &mut SolveApp, mut slot: DocSlot) -> DocSlot { agent_chat: slot.agent_chat, link_stamps: std::mem::replace(&mut app.docs.link_stamps, slot.link_stamps), edit_in_place: std::mem::replace(&mut app.edit_in_place.active, slot.edit_in_place), + work, }; // Only the displayed design owns the process-wide crash replay journal. app.session.crash_journal = out.session.crash_journal; @@ -186,9 +248,7 @@ fn swap_in(app: &mut SolveApp, mut slot: DocSlot) -> DocSlot { if let Some(pending) = &mut app.pending_feature_edit { pending.view_changed(before, app.session.revision); } - app.ui.ribbon_context = None; - app.ui.tab = "MODEL".into(); - crate::workspace::sync_context(app); + put_work(app, slot.work, before); out } @@ -315,6 +375,9 @@ pub(crate) fn reuse_path(app: &mut SolveApp, path: &str) -> Result app.docs.slots.iter().position(|s| s.as_ref().is_some_and(|s| matches(&s.session))) }; let Some(index) = index else { return Ok(false) }; + if app.agent_chat.following_document && index != app.docs.active { + return Err("the design is already open in another tab; select that tab to continue in its own conversation".into()); + } if app.file_picker_busy() { return Err("finish choosing a file before switching designs".into()); } @@ -354,7 +417,7 @@ pub fn open_tab(app: &mut SolveApp, session: Session) -> Result<(), String> { } let at = app.docs.active + 1; app.docs.slots.insert(at, Some(DocSlot::new(session))); - switch(app, at); + crate::agent_chat::follow_new_tab(app, |app| switch(app, at)); app.fit_view(); Ok(()) } @@ -370,19 +433,21 @@ pub fn active_is_blank(app: &SolveApp) -> bool { pub fn adopt(app: &mut SolveApp, session: Session) { if active_is_blank(app) { let rev = app.session.revision; - reset_chat(app); crate::dialogs::cancel(app); app.tool = None; crate::design_properties::close_object(app); crate::sketch_attachment_ui::clear(app); app.session.replace_design(session); + reset_chat(app); // The blank design's record of its links goes with it; the new one's start now. app.docs.link_stamps.clear(); - crate::linked_source::note_unread(app); crate::render_workspace::reset_document(app); app.session.invalidate_view(rev); app.cam = Camera::default(); app.fit_view(); + // The linked designs that changed while it was closed are read again (#9739): last, as a + // read is taken up only by the design revision it started from. + crate::linked_source::update_changed(app); } else if let Err(e) = open_tab(app, session) { app.set_status(e, true); } @@ -491,8 +556,12 @@ pub fn close(app: &mut SolveApp, i: usize, force: bool) { } } -/// A replacement design starts a fresh conversation while retaining the window's chat services. +/// A replacement starts a fresh conversation, unless the current chat itself requested it. pub(crate) fn reset_chat(app: &mut SolveApp) { + if app.agent_chat.following_document { + crate::agent_chat::continue_in_document(app); + return; + } // A chat not asked anything yet (an agent connected at start, say) holds nothing of the old // design: it starts over in place, and its agent stays connected. if !app.agent_chat.items.iter().any(|i| matches!(i, crate::agent_chat::Item::User { .. })) { @@ -1779,4 +1848,98 @@ mod tests { assert_eq!(v["active"], 0); assert_eq!(v["documents"].as_array().map(Vec::len), Some(2)); } + + /// The items picked in the open dialog's first input. + fn dialog_picks(a: &SolveApp) -> Vec { + a.dialog.as_ref().and_then(|d| d.inputs.first()).map(|i| i.items.clone()).unwrap_or_default() + } + + /// Tab 1: a box measured between two corners, on INSPECT. Tab 2: a sketch with the Line tool. + fn measuring_and_drawing(keep: bool) -> (SolveApp, Vec) { + let mut a = app(); + a.preferences.keep_work_on_tab_switch = keep; + a.run("solid.box", json!({"length": 3, "width": 4, "height": 12, "body_name": "Box"})).unwrap(); + a.session.selection = + [[0.0, 0.0, 0.0], [3.0, 4.0, 12.0]].into_iter().map(|p| solidcraft_engine::Sel::Vertex { body: "Box".into(), point: p.into() }).collect(); + a.ui.tab = "INSPECT".into(); + a.start("inspect.measure"); + let picks = dialog_picks(&a); + assert_eq!(picks.len(), 2, "Measure opens on the two picked corners"); + new_design(&mut a); + a.run("sketch.create", json!({"plane": "XY"})).unwrap(); + crate::workspace::sync_context(&mut a); + a.start("sketch.line"); + assert!(a.tool.as_ref().is_some_and(|t| t.cmd == "sketch.line") && a.ui.tab == "SKETCH"); + (a, picks) + } + + /// #9360: with "Keep dialogs and tools open when switching designs", each design tab has its + /// open dialog (with its picks), its tool and its toolbar tab back when it is shown again, and + /// none of them shows in the other design. + #[test] + fn tabs_keep_their_dialog_tool_and_toolbar_tab_with_the_preference() { + let (mut a, picks) = measuring_and_drawing(true); + switch(&mut a, 0); + assert!(a.tool.is_none(), "the Line tool stays with the sketch's design"); + assert!(matches!(a.dialog.as_ref().map(|d| &d.kind), Some(crate::dialogs::Kind::Measure { .. })), "Measure is open again"); + assert_eq!(dialog_picks(&a), picks, "with the same two corners"); + assert_eq!(a.ui.tab, "INSPECT"); + assert!(a.session.active_sketch.is_none()); + switch(&mut a, 1); + assert!(a.dialog.is_none(), "Measure stays with the box's design"); + assert!(a.tool.as_ref().is_some_and(|t| t.cmd == "sketch.line"), "the Line tool is back"); + assert!(a.session.active_sketch.is_some()); + assert_eq!(a.ui.tab, "SKETCH"); + // Kept work comes back even once the preference is off; after that, a switch cancels. + a.preferences.keep_work_on_tab_switch = false; + switch(&mut a, 0); + assert!(dialog_picks(&a) == picks && a.ui.tab == "INSPECT"); + switch(&mut a, 1); + switch(&mut a, 0); + assert!(a.dialog.is_none() && a.ui.tab == "MODEL"); + } + + /// Without the preference (the default), a switch cancels the dialog and the tool and shows + /// MODEL, as before #9360. + #[test] + fn a_tab_switch_cancels_the_dialog_and_tool_by_default() { + assert!(!crate::prefs::Prefs::default().keep_work_on_tab_switch); + let (mut a, _) = measuring_and_drawing(false); + switch(&mut a, 0); + assert!(a.dialog.is_none() && a.tool.is_none()); + assert_eq!(a.ui.tab, "MODEL"); + switch(&mut a, 1); + assert!(a.dialog.is_none() && a.tool.is_none()); + // The sketch itself stays open in its design, so its tab is SKETCH. + assert_eq!(a.ui.tab, "SKETCH"); + } + + /// #9360: a feature being edited keeps its history rolled back to it in its own design only, + /// and its Cancel, after the switch back, puts that design's history back. + #[test] + fn a_feature_edit_waits_in_its_tab_and_cancels_there() { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.app.preferences.keep_work_on_tab_switch = true; + h.app.run("solid.box", json!({"length": 10, "width": 10, "height": 10})).unwrap(); + h.app.run("solid.box", json!({"length": 5, "width": 5, "height": 30})).unwrap(); + let features = h.app.session.doc.features.len(); + let first = h.app.session.doc.features.first().map(|f| f.id).unwrap(); + h.call("ui.editFeature", json!({"feature": first})); + h.frames(3); + let editing = |h: &crate::scenario::Harness| h.app.dialog.as_ref().and_then(|d| d.editing).map(|(id, _)| id); + assert_eq!(editing(&h), Some(first), "the first box's dialog is open"); + let marker = h.app.session.doc.marker; + new_design(&mut h.app); + h.frames(2); + assert!(h.app.dialog.is_none() && h.app.editing_state.is_none()); + assert!(h.app.session.doc.features.is_empty() && h.app.session.doc.marker.is_none(), "the new design is untouched"); + switch(&mut h.app, 0); + h.frames(2); + assert_eq!(editing(&h), Some(first), "the edit is open again"); + assert_eq!(h.app.session.doc.marker, marker, "with the history where the edit left it"); + crate::dialogs::cancel(&mut h.app); + assert!(h.app.dialog.is_none()); + assert_eq!((h.app.session.doc.features.len(), h.app.session.doc.marker), (features, None), "Cancel puts the history back"); + } } diff --git a/crates/ui-egui/src/drag_snap.rs b/crates/ui-egui/src/drag_snap.rs index f59650ba4b..76222262a4 100644 --- a/crates/ui-egui/src/drag_snap.rs +++ b/crates/ui-egui/src/drag_snap.rs @@ -9,7 +9,7 @@ use std::cell::{Cell, RefCell}; use egui::{Align2, FontId, Stroke}; use serde_json::{Value, json}; use solidcraft_engine::geom::Vec2; -use solidcraft_engine::sketch::{CurveKind, Sketch}; +use solidcraft_engine::sketch::{ConstraintKind, CurveKind, Sketch}; use crate::SolveApp; use crate::theme::Tokens; @@ -175,20 +175,51 @@ pub fn find(sk: &Sketch, moving: &[(usize, Vec2)], exclude: &[usize], mm_px: f64 keep(Some(Snap { point: id(mi), at, offset, target: Target::Align, guides })) } -/// Points the drag of `entity` (a point or curve id) moves, and the points that move with them. -pub fn moving_points(sk: &Sketch, entity: &str) -> (Vec, Vec) { - let own: Vec = if let Some(p) = sk.point_index(entity) { - vec![p] - } else { - match sk.curve_index(entity).and_then(|c| sk.curves.get(c)) { - // Only lines slide whole; a circle or arc grabbed on its edge changes its radius. - Some(c) if matches!(c.kind, CurveKind::Line { .. }) => c.kind.point_ids(), - _ => Vec::new(), +/// Points the drag of `entities` (point or curve ids: the one grabbed and those dragged with it) +/// moves, and the points that move with them. +pub fn moving_points<'a>(sk: &Sketch, entities: impl IntoIterator) -> (Vec, Vec) { + let mut own: Vec = entities + .into_iter() + .flat_map(|entity| { + if let Some(p) = sk.point_index(entity) { + vec![p] + } else { + match sk.curve_index(entity).and_then(|c| sk.curves.get(c)) { + // Only lines slide whole; a circle or arc grabbed on its edge changes its radius. + Some(c) if matches!(c.kind, CurveKind::Line { .. }) => c.kind.point_ids(), + _ => Vec::new(), + } + } + }) + .collect(); + // A point held at the middle of a line (a centre rectangle's centre on its diagonal) goes + // where the line's ends go, and dragging it takes the line along. So it moves, and snaps, + // with a line whose ends both move; and when it is dragged, its line and the shape round it + // are no targets for it: they held it at their own middle instead of lining it up (#10086). + let middles: Vec<(usize, usize, usize)> = sk + .constraints + .iter() + .filter(|c| !c.driven) + .filter_map(|c| match c.kind { + ConstraintKind::Midpoint { p, l } => match sk.curves.get(l).map(|c| &c.kind) { + Some(CurveKind::Line { a, b }) => Some((p, *a, *b)), + _ => None, + }, + _ => None, + }) + .collect(); + for &(p, a, b) in &middles { + if own.contains(&a) && own.contains(&b) && !own.contains(&p) { + own.push(p); } - }; - let mut ex = own.clone(); + } + own.sort_unstable(); + own.dedup(); + let mut moved = own.clone(); + moved.extend(middles.iter().filter(|(p, ..)| own.contains(p)).flat_map(|&(_, a, b)| [a, b])); + let mut ex = moved.clone(); for c in &sk.curves { - if own.iter().any(|&p| c.kind.uses(p)) { + if moved.iter().any(|&p| c.kind.uses(p)) { ex.extend(c.kind.point_ids()); } } @@ -249,10 +280,47 @@ mod tests { } fn drag(s: &Sketch, entity: &str, p: Vec2) -> Option { - let (own, ex) = moving_points(s, entity); + let (own, ex) = moving_points(s, [entity]); find(s, &[(own[0], p)], &ex, 0.1) } + /// A centre rectangle 30 × 20 round `center`, as Center Rectangle draws it: the centre `p5` + /// held at the middle of the diagonal `l5`. + fn centre_rectangle(center: [f64; 2]) -> Sketch { + let mut s = solidcraft_engine::Session::default(); + let _ = s.execute("sketch.create", &json!({"plane": "XY"})); + let corner = [center[0] + 15.0, center[1] + 10.0]; + let _ = s.execute("sketch.rectangle.center", &json!({"center": center, "corner": corner})); + s.active_sketch.and_then(|id| s.doc.sketch(id).ok()).cloned().unwrap_or_else(Sketch::new) + } + + /// #10086: dragged by its centre, a centre rectangle comes along with it, and the centre + /// lines up with the origin (the X axis here), as a circle's centre does. Its own diagonal, + /// moving with it, held it at the diagonal's middle instead. Each frame of a drag snaps + /// against the shape where the last frame left it: here just above the axis. + #[test] + fn a_centre_rectangles_centre_lines_up_when_dragged() { + let s = centre_rectangle([30.0, 0.5]); + let snap = drag(&s, "p5", Vec2::new(30.0, 0.3)).expect("a snap"); + assert_eq!(snap.target, Target::Align, "{snap:?}"); + assert_eq!(snap.at, Vec2::new(30.0, 0.0)); + } + + /// #10086: the whole rectangle dragged (its four sides selected) moves its centre too, and the + /// centre lines up as its corners do. + #[test] + fn a_dragged_centre_rectangle_moves_and_lines_up_its_centre() { + let s = centre_rectangle([30.0, 5.0]); + let centre = s.resolve_point("p5").unwrap_or(0); + let (own, ex) = moving_points(&s, ["l1", "l2", "l3", "l4"]); + assert!(own.contains(¢re) && ex.contains(¢re), "{own:?} {ex:?}"); + let d = Vec2::new(0.0, -4.7); + let moving: Vec<(usize, Vec2)> = own.iter().filter_map(|&p| s.point(p).map(|x| (p, x + d))).collect(); + let snap = find(&s, &moving, &ex, 0.1).expect("a snap"); + assert_eq!((snap.point.as_str(), &snap.target), ("p5", &Target::Align), "{snap:?}"); + assert_eq!(snap.at, Vec2::new(30.0, 0.0)); + } + #[test] fn a_dragged_end_snaps_to_points_midpoints_and_curves() { let s = sk(); diff --git a/crates/ui-egui/src/edit_clipboard.rs b/crates/ui-egui/src/edit_clipboard.rs index 70033ce997..9efbfabf3c 100644 --- a/crates/ui-egui/src/edit_clipboard.rs +++ b/crates/ui-egui/src/edit_clipboard.rs @@ -17,7 +17,10 @@ //! and copied components as independent copies with their history (Paste and Paste New alike), //! where they were in the world (`component.paste_from_design`); completed sketches paste as //! independent features with their parameters (`timeline.paste`); cut ones paste there as copies, -//! the originals staying. The last copy or cut in any design is what pastes: a design's own +//! the originals staying. Sketch geometry copied or cut in a sketch pastes into a sketch of +//! another design (`sketch.paste`), at the cursor or at the copied coordinates, with the +//! constraints and dimensions among it; a dimension that named the copied design's parameters +//! keeps its value instead (#9258). The last copy or cut in any design is what pastes: a design's own //! clipboard empties when another design copied after it. //! //! The window system turns Ctrl+C / Ctrl+V into clipboard events, not key presses, and only sends @@ -106,6 +109,17 @@ fn foreign(app: &SolveApp) -> Option<&std::sync::Arc Option<&solidcraft_engine::cmd::sketch_clipboard::SketchClip> { + app.foreign_sketch_clip.as_ref().filter(|_| app.own_clipboard_stamp != app.clipboard_stamp) +} + +/// Is there sketch geometry to paste into the active sketch: this design's, or another's copied +/// last? +pub fn can_paste_sketch(app: &SolveApp) -> bool { + app.session.sketch_clipboard.is_some() || foreign_sketch(app).is_some() +} + /// Bodies another design copied or cut last: they paste here as imported bodies. fn foreign_bodies(app: &SolveApp) -> bool { foreign(app).is_some_and(|c| !c.bodies.is_empty()) @@ -126,7 +140,7 @@ pub(crate) fn drop_stale(app: &mut SolveApp) { /// Is there something Paste would paste here? pub fn can_paste(app: &SolveApp) -> bool { if app.session.active_sketch.is_some() { - app.session.sketch_clipboard.is_some() + can_paste_sketch(app) } else if app.linked_design_clipboard.is_some() { // A copied design is what pastes, and never into itself. !design_copied_here(app) @@ -241,6 +255,7 @@ fn replace_clipboard(app: &mut SolveApp) { app.clipboard_stamp += 1; app.own_clipboard_stamp = app.clipboard_stamp; app.foreign_copy = None; + app.foreign_sketch_clip = None; app.copied_bodies.clear(); app.copied_placements.clear(); app.cut = CutSet::default(); @@ -290,6 +305,7 @@ pub fn copy(app: &mut SolveApp) -> bool { // The command says why when it fails. let Ok(out) = app.run("sketch.copy", json!({ "entities": entities })) else { return copy_failed(app, None) }; replace_clipboard(app); + app.foreign_sketch_clip = app.session.sketch_clipboard.clone(); let n = out.get("curves").and_then(Value::as_u64).unwrap_or(0) + out.get("points").and_then(Value::as_u64).unwrap_or(0); let k = out.get("constraints").and_then(Value::as_u64).unwrap_or(0); let text = format!("SolidCraft sketch geometry: {n} entities, {k} constraints and dimensions"); @@ -460,6 +476,7 @@ pub fn cut(app: &mut SolveApp) -> bool { } let Ok(out) = app.run("sketch.cut", json!({ "entities": entities })) else { return copy_failed(app, None) }; replace_clipboard(app); + app.foreign_sketch_clip = app.session.sketch_clipboard.clone(); let n = out.get("curves").and_then(Value::as_u64).unwrap_or(0) + out.get("points").and_then(Value::as_u64).unwrap_or(0); app.set_status(format!("Cut {n} sketch entities"), false); leave_text(app, format!("SolidCraft sketch geometry: {n} entities")); @@ -500,6 +517,11 @@ pub fn paste_into(app: &mut SolveApp, at: Option, new_copy: bool, into: Op (active, app.session.active_occurrence_path.clone().unwrap_or_else(|| app.session.doc.first_path(active))) }); if app.session.active_sketch.is_some() { + // Another design copied sketch geometry last: it pastes here at the same sketch + // coordinates, its dimensions keeping their values rather than that design's parameters. + if let Some(clip) = foreign_sketch(app).map(|c| c.for_another_design()) { + app.session.sketch_clipboard = Some(clip); + } if app.session.sketch_clipboard.is_none() { app.set_status("Nothing to paste: copy sketch geometry first", true); return false; diff --git a/crates/ui-egui/src/edit_clipboard_tests.rs b/crates/ui-egui/src/edit_clipboard_tests.rs index e37e8d7d92..e7bee3e620 100644 --- a/crates/ui-egui/src/edit_clipboard_tests.rs +++ b/crates/ui-egui/src/edit_clipboard_tests.rs @@ -222,6 +222,41 @@ fn completed_sketch_copies_and_pastes_into_a_new_design() { assert!(h.app.session.model.result(copied.id).unwrap().error.is_none()); } +/// Sketch geometry copied in a sketch pastes into a sketch of another design (tab) with Ctrl+V, +/// at the cursor, with its constraints and dimensions, in one undo step; the context menu offers +/// it there too. A copy made since in that design is what pastes back in the first (#9258). +#[test] +fn sketch_geometry_copies_into_a_sketch_of_another_design() { + let (mut h, _) = rectangle(); + clipboard_event(&mut h, egui::Event::Copy, Some([10.0, 5.0])); + crate::documents::new_tab(&mut h.app); + assert!(!super::can_paste(&h.app), "outside a sketch, sketch geometry has nowhere to go"); + h.app.run("sketch.create", json!({"plane": "XY"})).unwrap(); + h.call("ui.view", json!({"view": "top"})); + h.call("ui.camera", json!({"target": [20, 10, 0], "distance": 150})); + h.frames(4); + assert!(super::can_paste(&h.app)); + let menu = crate::context_menu::items(&h.app, &crate::context_menu::Target::Viewport); + assert!(menu.iter().any(|i| i.id == "ui.paste" && i.enabled), "the sketch menu's Paste is on"); + let undo = h.app.session.undo.len(); + clipboard_event(&mut h, egui::Event::Paste("SolidCraft sketch geometry".into()), Some([60.0, 40.0])); + let pasted = curves(&h); + assert_eq!(pasted.len(), 4, "{:?}", h.app.status); + let corner = start(&h, &pasted[0]); + assert!(corner.dist(Vec2::new(50.0, 35.0)) < 1.0, "{corner:?}"); + let sk = h.app.session.doc.sketch(h.app.session.active_sketch.unwrap()).unwrap(); + assert_eq!(sk.constraints.iter().filter(|c| c.param.is_some()).count(), 2, "both dimensions came along"); + assert_eq!(h.app.session.undo.len(), undo + 1); + // A copy in this design is now the last: the first design pastes it. + let items: Vec = pasted.iter().take(1).map(|id| json!({"type": "sketch_curve", "id": id})).collect(); + h.app.run("select.set", json!({ "items": items })).unwrap(); + assert!(super::copy(&mut h.app)); + crate::documents::switch(&mut h.app, 0); + let before = curves(&h).len(); + assert!(super::paste(&mut h.app, None), "{:?}", h.app.status); + assert_eq!(curves(&h).len(), before + 1, "the one line copied last, not the rectangle copied here before"); +} + #[test] fn copied_sketch_survives_source_close_and_pastes_from_a_sketch_group_menu() { use crate::context_menu::{Target, items, run_item}; diff --git a/crates/ui-egui/src/extrude_edit_tests.rs b/crates/ui-egui/src/extrude_edit_tests.rs index 47fead1964..b2a4c4bfbd 100644 --- a/crates/ui-egui/src/extrude_edit_tests.rs +++ b/crates/ui-egui/src/extrude_edit_tests.rs @@ -25,6 +25,69 @@ fn open(h: &mut Harness) { h.frames(8); } +#[test] +fn undoing_the_edited_feature_closes_its_editor_and_preserves_redo() { + let mut h = make(json!({})); + let feature = h.app.session.doc.find_feature("Depth").unwrap().id; + open(&mut h); + assert!(h.app.dialog.is_some()); + + h.app.start("edit.undo"); + h.frames(8); + assert!(h.app.session.doc.feature(feature).is_none()); + assert!(h.app.dialog.is_none(), "a removed feature must not keep an Edit Extrude dialog"); + assert!(h.app.editing_state.is_none()); + assert!(h.app.applying.is_none()); + assert!(!h.app.preview.active); + assert!(h.app.session.feature_edit_sketch.is_none()); + assert_eq!(h.app.session.redo.len(), 1); + assert_eq!(h.app.session.doc.features.len(), 1); + let revision = h.app.session.revision; + h.frames(8); + assert_eq!(h.app.session.revision, revision, "closing the stale editor does not change the design"); + + h.app.run("edit.redo", json!({})).unwrap(); + assert!(h.app.session.doc.feature(feature).is_some()); + assert!(h.app.dialog.is_none(), "redo does not resurrect the old editor"); + assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds().max.z, 10.0); + open(&mut h); + h.step(&json!({"widget":"OK"})).unwrap(); + assert!(h.app.dialog.is_none(), "the restored feature can be edited normally"); +} + +#[test] +fn undo_that_keeps_the_edited_feature_keeps_its_editor() { + let mut h = make(json!({})); + let feature = h.app.session.doc.find_feature("Depth").unwrap().id; + h.app.run("solid.box", json!({"corner":[30,0,0],"length":2,"width":2,"height":2})).unwrap(); + open(&mut h); + h.app.start("edit.undo"); + h.frames(8); + assert!(h.app.session.doc.feature(feature).is_some()); + assert_eq!(h.app.dialog.as_ref().unwrap().editing.map(|(id, _)| id), Some(feature)); + assert!(h.app.editing_state.is_some()); + assert!(h.app.session.feature_edit_sketch.is_some()); + assert_eq!(h.app.session.redo.len(), 1); +} + +#[test] +fn deleting_the_edited_feature_closes_its_editor_and_undo_restores_the_design() { + let mut h = make(json!({})); + let original = h.app.session.doc.clone(); + open(&mut h); + h.app.run("timeline.delete", json!({"feature":"Depth"})).unwrap(); + let deleted = h.app.session.doc.clone(); + let history = (h.app.session.undo.len(), h.app.session.redo.len()); + h.frames(8); + assert!(h.app.dialog.is_none() && h.app.editing_state.is_none()); + assert!(!h.app.preview.active); + assert!(std::sync::Arc::ptr_eq(&deleted, &h.app.session.doc), "closing preserves the current document and marker"); + assert_eq!((h.app.session.undo.len(), h.app.session.redo.len()), history); + h.app.run("edit.undo", json!({})).unwrap(); + assert!(std::sync::Arc::ptr_eq(&original, &h.app.session.doc), "Undo restores the document before the temporary editor rollback"); + assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds().max.z, 10.0); +} + #[test] fn unchanged_all_profiles_includes_profiles_added_after_edit() { let mut h = make(json!({})); diff --git a/crates/ui-egui/src/file_load.rs b/crates/ui-egui/src/file_load.rs index 69bcff7987..b5dc74bdb3 100644 --- a/crates/ui-egui/src/file_load.rs +++ b/crates/ui-egui/src/file_load.rs @@ -38,7 +38,7 @@ pub enum Kind { /// Export a snapshot of the current design without adopting the worker's session. ExportDxf, /// Read the active design's linked designs again (`component.update_links`, with the - /// request's options: `component` reads only that link). + /// request's options: `component` or `components` reads only those links). UpdateLinks, } @@ -58,6 +58,8 @@ pub struct Request { /// The saved design this request is for, when it must not run on another: one shown in its /// place by the time the request starts drops it. pub design: Option, + /// The chat turn that requested the load. It must still be current when the file lands. + pub(crate) chat_turn: Option>, } impl Request { @@ -70,6 +72,7 @@ impl Request { options: Default::default(), allow_hosts: Vec::new(), design: None, + chat_turn: None, } } fn insert(&self) -> bool { @@ -94,6 +97,10 @@ impl Request { } fn answer(&self, r: Result) { if let Some(reply) = &self.reply { + if crate::control::chat_turn_cancelled(self.chat_turn.as_deref()) { + let _ = reply.send(crate::control::cancelled_chat_response()); + return; + } let _ = reply.send(match r { Ok(v) => json!({"ok": true, "result": v}), Err(e) => json!({"ok": false, "error": e}), @@ -183,7 +190,7 @@ impl Running { Arc::ptr_eq(&session.doc, &self.base) && session.revision == self.base_revision } fn cancelled(&self) -> bool { - self.progress.is_cancelled() + self.progress.is_cancelled() || crate::control::chat_turn_cancelled(self.request.chat_turn.as_deref()) } } @@ -241,6 +248,10 @@ pub fn begin(app: &mut SolveApp, path: &str, insert: bool) { /// Queue a file operation; on the web (no threads) it runs now. pub fn begin_request(app: &mut SolveApp, request: Request) -> Result<(), String> { + if crate::control::chat_turn_cancelled(request.chat_turn.as_deref()) { + request.answer(Err("the agent chat turn ended".into())); + return Ok(()); + } if app.file_load.idle() && reuse_open(app, &request) { return Ok(()); } @@ -320,7 +331,7 @@ fn reuse_open(app: &mut SolveApp, request: &Request) -> bool { if !request.options.is_empty() { return Ok(false); } - crate::documents::reuse_path(app, &request.path) + crate::agent_chat::with_tool_request(app, request.chat_turn.as_ref(), |app| crate::documents::reuse_path(app, &request.path)) }); match reused { Ok(false) => false, @@ -371,7 +382,16 @@ pub fn control(app: &mut SolveApp, req: crate::control::ControlRequest) -> Optio options.remove("path"); options.retain(|_, v| !v.is_null()); } - let request = Request { path: path.into(), kind, reply: Some(req.reply.clone()), scope, options, allow_hosts: Vec::new(), design: None }; + let request = Request { + path: path.into(), + kind, + reply: Some(req.reply.clone()), + scope, + options, + allow_hosts: Vec::new(), + design: None, + chat_turn: req.chat_turn, + }; let _ = begin_request(app, request); None } @@ -405,6 +425,10 @@ fn start(app: &mut SolveApp) { return; } let Some(request) = app.file_load.queued.pop_front() else { return }; + if crate::control::chat_turn_cancelled(request.chat_turn.as_deref()) { + request.answer(Err("the agent chat turn ended".into())); + return; + } if reuse_open(app, &request) { return; } @@ -865,6 +889,18 @@ fn compute( } pub fn tick(app: &mut SolveApp, ctx: &egui::Context) { + // Stop must release the waiting slot even if the worker has not reached its next check. + let chat_cancelled = app.file_load.running.as_ref().is_some_and(|r| crate::control::chat_turn_cancelled(r.request.chat_turn.as_deref())); + if chat_cancelled && let Some(running) = &app.file_load.running { + running.progress.cancel(); + } + // As with FileLoad::cancel, a publishing export retains its worker until it reports back. + if chat_cancelled + && app.file_load.running.as_ref().is_some_and(|r| r.request.kind != Kind::ExportDxf) + && let Some(running) = app.file_load.running.take() + { + running.request.answer(Err("the agent chat turn ended".into())); + } if let Some(running) = &mut app.file_load.running { step_staged(running); } @@ -906,11 +942,16 @@ pub fn tick(app: &mut SolveApp, ctx: &egui::Context) { let (id, _) = request.command(); app.session.accept_computed(made.session); app.big_operation = true; - crate::linked_source::read_again(app, request.options.get("component").and_then(Value::as_u64)); + crate::linked_source::read_again(app, &request.options, &made.result); let r = Ok(made.result); app.finish_command(id, &r); let language = crate::i18n::Locale::for_language(&app.preferences.language); app.set_status(language.format("ui.command_done", &[("command", language.label(crate::command_label(id)))]), false); + // A design it could not read is said as when that was all it was asked. + if let Some(f) = r.as_ref().ok().and_then(|v| v["failed"].as_array()).and_then(|f| f.first()) { + let (name, why) = (f["name"].as_str().unwrap_or_default(), f["error"].as_str().unwrap_or_default()); + app.set_status(format!("{name}: {why}"), true); + } request.answer(r); } Ok(_) if reuse_open(app, request) => {} @@ -922,7 +963,7 @@ pub fn tick(app: &mut SolveApp, ctx: &egui::Context) { app.session.accept_computed(made.session); app.big_operation = true; } else { - crate::documents::adopt(app, made.session); + crate::agent_chat::with_tool_request(app, request.chat_turn.as_ref(), |app| crate::documents::adopt(app, made.session)); if request.kind == Kind::Open { app.home.add_recent(&request.path); } diff --git a/crates/ui-egui/src/fonts.rs b/crates/ui-egui/src/fonts.rs index 598f7fe564..300f877e23 100644 --- a/crates/ui-egui/src/fonts.rs +++ b/crates/ui-egui/src/fonts.rs @@ -387,7 +387,8 @@ impl CjkFontLoader { self.status } - /// Called every frame with the live language. `ready` is true when the shell already has the + /// Called every frame with the live language (a `system` preference resolves to its catalog + /// via [`crate::i18n::effective_language`]). `ready` is true when the shell already has the /// on-demand face that language needs. Returns true when a fetch should start now. After a /// failed load a CJK language falls back to English for this session; the saved preference is /// kept (see [`Self::persisted_language`]). Switching to another CJK language that still needs @@ -400,7 +401,7 @@ impl CjkFontLoader { self.status = CjkFontStatus::Unloaded; } } - if !needs_cjk_font(language) { + if !needs_cjk_font(crate::i18n::effective_language(language)) { return false; } if ready { @@ -436,13 +437,14 @@ impl CjkFontLoader { self.status == CjkFontStatus::Unloaded } - /// Record the outcome of the fetch and install. + /// Record the outcome of the fetch and install. A `system` preference resolves to its + /// catalog first ([`Self::tick`]). pub fn finished(&mut self, installed: bool, language: &mut String) { if installed { self.status = CjkFontStatus::Installed; } else { self.status = CjkFontStatus::Failed; - if needs_cjk_font(language) { + if needs_cjk_font(crate::i18n::effective_language(language)) { self.fall_back(language); } } @@ -1241,8 +1243,10 @@ mod tests { } #[test] - fn default_ui_language_is_english() { - assert_eq!(crate::prefs::Prefs::default().language, "en"); + fn default_ui_language_follows_the_system() { + // A first start follows the system (#10138); English, the fallback it lands on when + // nothing matches, needs no CJK face. + assert_eq!(crate::prefs::Prefs::default().language, crate::i18n::SYSTEM); assert!(!needs_cjk_font("en")); } diff --git a/crates/ui-egui/src/gpu.rs b/crates/ui-egui/src/gpu.rs index 2e6ddcfbcb..d9cd92072e 100644 --- a/crates/ui-egui/src/gpu.rs +++ b/crates/ui-egui/src/gpu.rs @@ -16,6 +16,20 @@ pub use solidcraft_gpu::view::{ /// A frame of the viewport as an egui paint callback. pub struct ViewportCallback(pub Frame); +/// The viewport callback composites transparently onto this egui GPU mesh. Both its clear +/// surface and gradient therefore use the same published/animated tokens as the panels; no +/// background colour belongs to a cached model scene or GPU upload. +pub(crate) fn paint_background(painter: &egui::Painter, rect: egui::Rect, tokens: &crate::theme::Tokens) { + let mut mesh = egui::Mesh::default(); + mesh.colored_vertex(rect.left_top(), tokens.viewport_top); + mesh.colored_vertex(rect.right_top(), tokens.viewport_top); + mesh.colored_vertex(rect.right_bottom(), tokens.viewport_bottom); + mesh.colored_vertex(rect.left_bottom(), tokens.viewport_bottom); + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(0, 2, 3); + painter.add(egui::Shape::mesh(mesh)); +} + /// The GPU plan evaluation compiling beside the window: there once it is ready ([`tick_tessellation`]). pub type TessReady = std::sync::Arc>; @@ -462,6 +476,7 @@ fn offscreen( ao_radius, ao_samples, ssaa, + sky: [0.0; 4], hover_tint: [0.0; 3], selection_tint: [0.0; 3], }); diff --git a/crates/ui-egui/src/history_off_thread_tests.rs b/crates/ui-egui/src/history_off_thread_tests.rs index 702b72bce4..36cd0263ab 100644 --- a/crates/ui-egui/src/history_off_thread_tests.rs +++ b/crates/ui-egui/src/history_off_thread_tests.rs @@ -682,6 +682,42 @@ fn a_measure_reads_a_copy_off_the_window_thread_preference_or_not() { assert_eq!(h.app.session.log.len(), log, "a reading leaves no echo: {:?}", h.app.session.log); } +/// #9624: an inspection read on a copy reports the design's undo and redo counts and its file, +/// as one run here does; the copy has no history and no file, and it said undo 0, redo 0, path +/// none, so an agent could not tell how far to roll back a failed batch. +#[test] +fn an_inspection_on_a_copy_reports_the_history_and_the_file() { + let mut h = Harness::new(); + h.app.home.open = false; + h.app.apply_off_thread = Some(Duration::ZERO); + let (tx, rx) = std::sync::mpsc::channel(); + h.app.control_rx = Some(rx); + let inspect = |h: &mut Harness| { + let reply = ask(&tx, "document.inspect", json!({})); + h.frames(1); + assert_eq!(h.app.control_answering.len(), 1, "read a copy"); + let r = reply_to(h, &reply); + assert_eq!(r["ok"], true, "{r}"); + let here = h.app.run("document.inspect", json!({})).unwrap(); + for key in ["undo", "redo", "path"] { + assert_eq!(r["result"][key], here[key], "{key}: {r}"); + } + (r["result"]["undo"].clone(), r["result"]["redo"].clone(), r["result"]["path"].clone()) + }; + for corner in [[0, 0, 0], [20, 0, 0]] { + h.app.run("solid.box", json!({"corner": corner, "length": 10, "width": 10, "height": 10})).unwrap(); + } + assert_eq!(inspect(&mut h), (json!(2), json!(0), json!(null))); + h.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(inspect(&mut h), (json!(1), json!(1), json!(null))); + let path = h.dir.join("boxes.solidcraft"); + h.app.run("file.save_as", json!({"path": path})).unwrap(); + let (undo, redo, saved) = inspect(&mut h); + assert_eq!((undo, redo), (json!(1), json!(1))); + assert_eq!(saved, json!(h.app.session.path), "the file it was saved to"); + assert!(saved.as_str().is_some_and(|p| p.ends_with("boxes.solidcraft")), "{saved}"); +} + /// A reading on a copy keeps the request's own `param_check` (#9184), as one run here does: its /// reply says the level, and a word that is no level is refused. #[test] @@ -886,6 +922,40 @@ fn delete_on_the_selection_checks_off_the_window_thread() { assert!((volume(&mut h) - block_with_hole()).abs() < 1.0); } +/// #9917: with the history rolled back, the check builds the features after the marker off the +/// window thread and keeps them; the confirmation carries them to the delete, which runs with +/// them instead of building that history again on the window thread. +#[test] +fn a_confirmed_delete_runs_with_what_its_check_kept() { + let mut h = Harness::new(); + h.app.home.open = false; + let sk = h.app.run("sketch.create", json!({"plane": "XY"})).unwrap()["sketch"].as_u64().unwrap(); + let side = h.app.run("sketch.line", json!({"points": [[0, 0], [40, 0], [30, 20], [10, 20]], "closed": true})).unwrap()["curves"][0] + .as_str() + .unwrap() + .to_string(); + h.app.run("sketch.finish", json!({})).unwrap(); + h.app.run("solid.extrude", json!({"distance": 5, "name": "Plate"})).unwrap(); + for i in 0..3 { + h.app.run("solid.cylinder", json!({"base": [12 + 6 * i, 8, -1], "radius": 1, "height": 10, "operation": "cut"})).unwrap(); + } + h.app.run("timeline.roll_to", json!({"position": 1})).unwrap(); + h.app.run("sketch.edit", json!({"sketch": sk})).unwrap(); + h.app.apply_off_thread = Some(Duration::ZERO); + crate::delete::request(&mut h.app, json!({"items": [{"type": "sketch_curve", "id": side}]})); + assert!(h.app.computing_since().is_some(), "checking"); + until_settled(&mut h); + let pending = h.app.menu.confirm.clone().expect("what the delete breaks after the marker is confirmed first"); + assert!(pending.report["would_fail"].as_array().is_some_and(|a| !a.is_empty()), "{}", pending.report); + let kept = pending.kept.expect("the check's results go with the confirmation"); + let built = kept.len(); + assert!(built > 0, "the check kept the features after the marker it built"); + assert!(crate::delete::confirm(&mut h.app, true)); + let curves = |h: &Harness| h.app.session.model.state().sketch(sk).map(|s| s.sketch.curves.len()).unwrap_or(0); + assert_eq!(curves(&h), 3, "deleted"); + assert_eq!(kept.len(), built, "the delete built nothing the check had not kept"); +} + /// A design changed while its delete is checked is checked again before anything is deleted, and /// a second Delete meanwhile is left out. #[test] diff --git a/crates/ui-egui/src/hole_place_ui_tests.rs b/crates/ui-egui/src/hole_place_ui_tests.rs index 771842b8c6..9f3acacb54 100644 --- a/crates/ui-egui/src/hole_place_ui_tests.rs +++ b/crates/ui-egui/src/hole_place_ui_tests.rs @@ -328,11 +328,11 @@ fn tapped_thread_depth_field_keeps_focus_and_typing_separate_from_acceptance() { assert_eq!(h.app.session.doc.to_json(), original, "Cancel preserves the accepted design"); } -/// The upstream Preferences split must retain both Hole controls on Design. +/// The Preferences split must retain both Hole controls on Tools. #[test] fn hole_design_preferences_keep_both_controls_and_cancel_after_page_split() { let mut h = Harness::new(); - h.call("ui.prefs", json!({"language":"en", "open":"Design"})); + h.call("ui.prefs", json!({"language":"en", "open":"Tools"})); let page = rect_of("prefs:page_body").unwrap(); for label in ["Snap to face and edge centers", "Move holes by clicking or dragging"] { let mut point = None; @@ -353,7 +353,7 @@ fn hole_design_preferences_keep_both_controls_and_cancel_after_page_split() { h.call("ui.scroll", json!({"x":page.center().x,"y":page.center().y,"delta":-100})); h.frames(12); } - let p = point.unwrap_or_else(|| panic!("Hole preference missing from Design: {label}")); + let p = point.unwrap_or_else(|| panic!("Hole preference missing from Tools: {label}")); h.call("ui.click", json!({"x":p.x,"y":p.y})); } assert!(h.app.preferences.hole_snaps && h.app.preferences.hole_moves); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index e4351e8093..ad2389f659 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -130,12 +130,50 @@ pub fn is_supported(id: &str) -> bool { LOCALES.iter().any(|catalog| catalog.id == id) } +/// The `language` preference value that follows the system (#10138). Not a catalog id: it +/// resolves through [`system_language`] wherever a catalog is needed. +pub const SYSTEM: &str = "system"; + +/// Match the system's preferred locale tags against the catalogs: the exact tag first +/// (`zh-TW`, `pt-BR`, `en-GB`), then the tag's base language (`zh-CN` → `zh`, `de-AT` → `de`), +/// then English. Tags come in the system's order of preference; the first one that matches wins. +pub fn match_system_tags(tags: &[String]) -> &'static str { + for tag in tags { + let tag = tag.replace('_', "-"); + if let Some(catalog) = LOCALES.iter().find(|catalog| catalog.id.eq_ignore_ascii_case(&tag)) { + return catalog.id; + } + let base = tag.split('-').next().unwrap_or(&tag); + if let Some(catalog) = LOCALES.iter().find(|catalog| catalog.id.eq_ignore_ascii_case(base)) { + return catalog.id; + } + } + ENGLISH.id +} + +/// The catalog id the system asks for, read once per process: the OS locale on desktop +/// (Windows, macOS, Linux/BSD) and `navigator.languages` in the browser. +pub fn system_language() -> &'static str { + static CACHED: OnceLock<&'static str> = OnceLock::new(); + CACHED.get_or_init(|| match_system_tags(&sys_locale::get_locales().collect::>())) +} + +/// The catalog id a `language` preference resolves to: the saved choice, or the system's for +/// [`SYSTEM`]. For callers that need the id as text (font fallbacks), not a [`Locale`]. +pub fn effective_language(preference: &str) -> &'static str { + Locale::for_language(preference).0.id +} + /// Immutable locale reference. Catalogs parse once; lookups are logarithmic. #[derive(Clone, Copy)] pub struct Locale(&'static Catalog); impl Locale { pub fn for_language(id: &str) -> Self { + if id == SYSTEM { + let system = system_language(); + return Self(LOCALES.iter().copied().find(|catalog| catalog.id == system).unwrap_or(&ENGLISH)); + } Self(LOCALES.iter().copied().find(|catalog| catalog.id == id).unwrap_or(&ENGLISH)) } @@ -277,6 +315,26 @@ thread_local! { // Helpers without an app reference share a render scope. Nested renders restore // their caller's locale; no owned language strings or persistent global setting. static CURRENT: Cell = const { Cell::new(Locale(&ENGLISH)) }; + /// Theme-authored language maps are already resolved and are not catalog source labels. + static RESOLVED_LABELS: Cell = const { Cell::new(false) }; +} + +#[must_use] +pub(crate) struct ResolvedLabels { + previous: bool, + _thread: std::marker::PhantomData>, +} + +/// Shared controls can render supplied translations verbatim while retaining catalog messages. +pub(crate) fn resolved_labels() -> ResolvedLabels { + let previous = RESOLVED_LABELS.with(|labels| labels.replace(true)); + ResolvedLabels { previous, _thread: std::marker::PhantomData } +} + +impl Drop for ResolvedLabels { + fn drop(&mut self) { + RESOLVED_LABELS.with(|labels| labels.set(self.previous)); + } } #[must_use] @@ -306,7 +364,7 @@ pub fn tr(id: &str) -> &str { CURRENT.with(|current| current.get().text(id)) } pub fn tr_label(label: &str) -> &str { - CURRENT.with(|current| current.get().label(label)) + if RESOLVED_LABELS.with(Cell::get) { label } else { CURRENT.with(|current| current.get().label(label)) } } /// Every English catalog entry `(id, text)`, for Preferences search to match a translation @@ -331,6 +389,59 @@ pub fn tr_diagnostic(text: &str) -> String { #[cfg(test)] mod tests { + /// The system's locale tags pick a catalog: exact tag, then base language, then English (#10138). + #[test] + fn system_tags_match_the_catalog_exact_then_base_then_english() { + let tags = |list: &[&str]| list.iter().map(|s| s.to_string()).collect::>(); + assert_eq!(super::match_system_tags(&tags(&["zh-TW"])), "zh-TW", "exact tag"); + assert_eq!(super::match_system_tags(&tags(&["pt-BR"])), "pt-BR"); + assert_eq!(super::match_system_tags(&tags(&["en-GB"])), "en-GB"); + assert_eq!(super::match_system_tags(&tags(&["zh-CN"])), "zh", "base language of zh-CN"); + assert_eq!(super::match_system_tags(&tags(&["de-AT"])), "de"); + assert_eq!(super::match_system_tags(&tags(&["zh_CN"])), "zh", "underscores normalize"); + assert_eq!(super::match_system_tags(&tags(&["FR"])), "fr", "case-insensitive"); + assert_eq!(super::match_system_tags(&tags(&["nl"])), "nl", "bare base tag"); + assert_eq!(super::match_system_tags(&tags(&["pt-PT"])), "en", "pt has no catalog: English"); + assert_eq!(super::match_system_tags(&tags(&["xx-YY"])), "en", "unknown: English"); + assert_eq!(super::match_system_tags(&tags(&[])), "en", "nothing: English"); + assert_eq!(super::match_system_tags(&tags(&["xx-YY", "de-DE"])), "de", "the first tag that matches wins"); + } + + /// A saved language is never overridden: only `system` resolves through the OS (#10138). + #[test] + fn a_saved_language_choice_is_never_overridden() { + assert_eq!(super::Locale::for_language("de").name(), "Deutsch"); + assert_eq!(super::Locale::for_language("zh-TW").name(), "繁體中文"); + assert_eq!(super::effective_language("de"), "de"); + assert_eq!(super::effective_language("ja"), "ja"); + // A first start has no saved language: the default follows the system. + assert_eq!(crate::prefs::Prefs::default().language, super::SYSTEM); + // A saved choice in the settings file wins over the default. + let saved: crate::prefs::Prefs = serde_json::from_str(r#"{"language":"ja"}"#).unwrap(); + assert_eq!(saved.language, "ja"); + // `system` is a preference value, not a catalog: it never enters the language list. + assert!(!super::is_supported(super::SYSTEM)); + assert!(super::is_supported(super::effective_language(super::SYSTEM)), "system resolves to a catalog"); + } + + #[test] + fn theme_options_resolved_labels_are_verbatim_and_restore_catalog_adapters() { + let _language = super::scope("de"); + assert_eq!(super::tr_label("Profile"), "Profil"); + { + let _labels = super::resolved_labels(); + assert_eq!(super::tr_label("Profile"), "Profile"); + assert_eq!(super::tr_label("ui.cancel"), "ui.cancel"); + assert_ne!(super::tr("ui.cancel"), "ui.cancel", "stable catalog messages remain translated"); + { + let _nested = super::resolved_labels(); + assert_eq!(super::tr_label("Profile"), "Profile"); + } + assert_eq!(super::tr_label("Profile"), "Profile"); + } + assert_eq!(super::tr_label("Profile"), "Profil"); + } + use super::*; /// Strings a pull request adds under `assets/locales/pending/` work before they are folded diff --git a/crates/ui-egui/src/i18n_ui_tests.rs b/crates/ui-egui/src/i18n_ui_tests.rs index 2f05ab72ab..776c3ebef5 100644 --- a/crates/ui-egui/src/i18n_ui_tests.rs +++ b/crates/ui-egui/src/i18n_ui_tests.rs @@ -62,6 +62,7 @@ fn sketch_palette_profile_and_points_rows_show_their_own_translation() { #[test] fn language_preference_cancel_restores_previous_locale() { let mut h = Harness::new(); + h.app.preferences.language = "en".into(); h.app.home.open = false; h.call("ui.prefs", json!({"open":"General"})); h.call("ui.prefs", json!({"language":"ja"})); @@ -72,6 +73,17 @@ fn language_preference_cancel_restores_previous_locale() { assert_eq!(h.app.tr("ui.preferences"), "Preferences"); } +/// A first start (no saved language) shows `System` as the interface language choice (#10138). +#[test] +fn language_dropdown_defaults_to_system() { + let mut h = Harness::new(); + h.app.home.open = false; + assert_eq!(h.app.preferences.language, "system"); + h.call("ui.prefs", json!({"open":"General"})); + let system = h.app.tr("prefs.language_system").to_owned(); + assert!(h.widget(&system).is_some(), "the language dropdown reads {system}"); +} + /// The chrome a user sees before touching anything: the browser heading and the SELECT panel. /// Both were English literals in the drawing code, and a broken message id falls back to /// English silently, so check the word actually rendered in each language. diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 05715320ba..3478838e9e 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -32,6 +32,14 @@ enum Finish { #[derive(Clone, Default)] struct Cache(Arc>); +/// Drop rasterised icon textures so the next paint uploads them again (wake from sleep, #8749). +pub(crate) fn clear_texture_cache(ctx: &egui::Context) { + let cache = ctx.data_mut(|d| d.get_temp_mut_or_insert_with(egui::Id::new("sc_icon_cache"), Cache::default).clone()); + if let Ok(mut entries) = cache.0.lock() { + entries.textures.clear(); + } +} + #[derive(Default)] struct CacheEntries { palette: Option, diff --git a/crates/ui-egui/src/inference.rs b/crates/ui-egui/src/inference.rs index 8ad9581717..cdf3079284 100644 --- a/crates/ui-egui/src/inference.rs +++ b/crates/ui-egui/src/inference.rs @@ -250,14 +250,23 @@ pub fn show(app: &SolveApp, painter: &egui::Painter, proj: &Proj) { } } -/// The two most recent hovered centres or straight-edge midpoints. Transient, per viewport, -/// never a constraint. A third hover drops the oldest. Endpoints are ordinary point snaps. +/// What a tracking guide starts from: a curve's centre or straight-edge midpoint, or, while a +/// drawing tool waits for a point, a sketch point itself (a line or arc end, a rectangle corner, +/// a free point; #9105). +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum TrackRef { + Curve(String), + Point(String), +} + +/// The two most recent hovered references ([`TrackRef`]). Transient, per viewport, never a +/// constraint. A third hover drops the oldest. #[derive(Default)] pub(crate) struct Tracking { sketch: Option, document: std::sync::Weak, - anchor: Option, - previous: Option, + anchor: Option, + previous: Option, pub(crate) guide: Option<(Vec2, Vec2)>, pub(crate) remembered_guide: Option<(Vec2, Vec2)>, } @@ -274,28 +283,58 @@ impl Tracking { } } - fn point_of(sk: &Sketch, id: &str) -> Option { - Self::reference(sk, sk.curve_index(id)?) + /// The sketch points a guide may start from: shown points other than the origin (the sketch + /// axes already run through it) that are free or belong to at least one shown curve. + fn point_references(sk: &Sketch) -> Vec { + let visible = solidcraft_engine::view::SketchPointVisibility::new(sk); + let mut used = vec![false; sk.points.len()]; + let mut shown = vec![false; sk.points.len()]; + for (index, curve) in sk.curves.iter().enumerate() { + let on = solidcraft_engine::view::sketch_curve_visible(sk, index); + for p in curve.kind.point_ids() { + if let Some(u) = used.get_mut(p) { + *u = true; + } + if on && let Some(s) = shown.get_mut(p) { + *s = true; + } + } + } + sk.points + .iter() + .enumerate() + .map(|(i, p)| i != 0 && visible.takes(p) && (shown.get(i).copied().unwrap_or(false) || !used.get(i).copied().unwrap_or(true))) + .collect() } - /// Keep `id` as one of the two remembered references. Hovering the older one swaps them, - /// so going back does not drop it. A third, different reference drops the oldest. - fn remember(&mut self, id: String) { - if self.anchor.as_deref() == Some(id.as_str()) { + fn point_of(sk: &Sketch, reference: &TrackRef) -> Option { + match reference { + TrackRef::Curve(id) => Self::reference(sk, sk.curve_index(id)?), + TrackRef::Point(id) => { + let index = sk.point_index(id)?; + Self::point_references(sk).get(index).copied().unwrap_or(false).then(|| sk.point(index)).flatten() + } + } + } + + /// Keep `reference` as one of the two remembered references. Hovering the older one swaps + /// them, so going back does not drop it. A third, different reference drops the oldest. + fn remember(&mut self, reference: TrackRef) { + if self.anchor.as_ref() == Some(&reference) { return; } - if self.previous.as_deref() == Some(id.as_str()) { + if self.previous.as_ref() == Some(&reference) { std::mem::swap(&mut self.anchor, &mut self.previous); return; } - self.previous = self.anchor.replace(id); + self.previous = self.anchor.replace(reference); } /// Where the horizontal through one reference meets the vertical through the other, when /// that point is within a few pixels of the cursor and is not either reference itself. pub(crate) fn meeting(&self, sk: &Sketch, plane: &solidcraft_engine::geom::Plane, proj: &Proj, cursor: egui::Pos2) -> Option { - let a = self.anchor.as_deref().and_then(|id| Self::point_of(sk, id))?; - let b = self.previous.as_deref().and_then(|id| Self::point_of(sk, id))?; + let a = self.anchor.as_ref().and_then(|r| Self::point_of(sk, r))?; + let b = self.previous.as_ref().and_then(|r| Self::point_of(sk, r))?; if a.dist(b) < 1e-6 { return None; } @@ -329,33 +368,57 @@ impl Tracking { end.map(|end| (anchor, end)) } - fn update(&mut self, sk: &Sketch, plane: &solidcraft_engine::geom::Plane, proj: &Proj, cursor: egui::Pos2) { + /// Follow the cursor: remember the reference under it and lay out the guides. With + /// `points`, sketch points are references too (a drawing tool waits for a point); + /// without, remembered points are let go. + fn update(&mut self, sk: &Sketch, plane: &solidcraft_engine::geom::Plane, proj: &Proj, cursor: egui::Pos2, points: bool) { self.guide = None; self.remembered_guide = None; + if !points { + for slot in [&mut self.anchor, &mut self.previous] { + if matches!(slot, Some(TrackRef::Point(_))) { + *slot = None; + } + } + if self.anchor.is_none() { + self.anchor = self.previous.take(); + } + } let to = |p: Vec2| proj.to_screen(plane.to_world(p)); // Screen-space tolerances stay the same at any zoom; only visible references wake. + // Centres and midpoints come first, so a point on one of them names the curve. let mut nearest = 8.0; - let mut nearest_id = None; + let mut nearest_ref = None; for (index, curve) in sk.curves.iter().enumerate() { let Some(p) = Self::reference(sk, index).and_then(to).filter(|p| proj.rect.contains(*p)) else { continue }; let distance = cursor.distance(p); if distance < nearest { nearest = distance; - nearest_id = Some(curve.id.clone()); + nearest_ref = Some(TrackRef::Curve(curve.id.clone())); } } - if let Some(id) = nearest_id { - self.remember(id); + if points { + for (point, usable) in sk.points.iter().zip(Self::point_references(sk)) { + let Some(p) = usable.then_some(point.pos).and_then(to).filter(|p| proj.rect.contains(*p)) else { continue }; + let distance = cursor.distance(p); + if distance < nearest { + nearest = distance; + nearest_ref = Some(TrackRef::Point(point.id.clone())); + } + } } - let Some(anchor) = self.anchor.as_deref().and_then(|id| Self::point_of(sk, id)).or_else(|| { + if let Some(reference) = nearest_ref { + self.remember(reference); + } + let Some(anchor) = self.anchor.as_ref().and_then(|r| Self::point_of(sk, r)).or_else(|| { self.anchor = self.previous.take(); - self.anchor.as_deref().and_then(|id| Self::point_of(sk, id)) + self.anchor.as_ref().and_then(|r| Self::point_of(sk, r)) }) else { self.anchor = None; self.previous = None; return; }; - if self.previous.as_deref().and_then(|id| Self::point_of(sk, id)).is_none() { + if self.previous.as_ref().and_then(|r| Self::point_of(sk, r)).is_none() { self.previous = None; } let Some(origin) = to(anchor) else { return }; @@ -373,7 +436,7 @@ impl Tracking { if let Some(meet) = self.meeting(sk, plane, proj, cursor) { let span = |from: Vec2| to(from).zip(to(meet)).filter(|(a, b)| a.distance(*b) > 8.0).map(|_| (from, meet)); self.guide = span(anchor); - if let Some(previous) = self.previous.as_deref().and_then(|id| Self::point_of(sk, id)) { + if let Some(previous) = self.previous.as_ref().and_then(|r| Self::point_of(sk, r)) { self.remembered_guide = span(previous); } return; @@ -381,7 +444,7 @@ impl Tracking { // Finite guides connect the reference to the cursor's projection onto its axis. // No model-space length cap: the viewport clips them, as it does other sketch guides. self.guide = Self::axis_guide(anchor, at, cursor, &to); - if let Some(previous) = self.previous.as_deref().and_then(|id| Self::point_of(sk, id)) { + if let Some(previous) = self.previous.as_ref().and_then(|r| Self::point_of(sk, r)) { self.remembered_guide = Self::axis_guide(previous, at, cursor, &to); } } @@ -389,11 +452,14 @@ impl Tracking { /// Visual H/V tracking works before a tool's first click and while simply editing a sketch. /// The crossing of the two remembered references is also a snap. A single guide stays visual. -/// Existing snaps, picks and constraints retain their behaviour. +/// Existing snaps, picks and constraints retain their behaviour. While a drawing tool waits for +/// a point, sketch points (ends, corners, free points) are references too, unless the Sketch +/// preferences turn that off; idle hovering only wakes centres and midpoints (#9105). pub(crate) fn track_and_show(app: &mut SolveApp, painter: &egui::Painter, proj: &Proj, cursor: Option) { crate::scenario::publish_count("sketch_tracking_guides", 0.0); let document = std::sync::Arc::downgrade(&app.session.doc); let active = app.session.active_sketch; + let points = app.preferences.sketch_tracking_points && app.tool.as_ref().is_some_and(|t| matches!(t.kind, crate::tools::Kind::Draw(_))); let tracking = &mut app.viewport.tracking; if tracking.sketch != active || !tracking.document.ptr_eq(&document) { *tracking = Tracking { sketch: active, document, ..Default::default() }; @@ -407,7 +473,7 @@ pub(crate) fn track_and_show(app: &mut SolveApp, painter: &egui::Painter, proj: *tracking = Tracking::default(); return; }; - tracking.update(&ss.sketch, &ss.plane, proj, cursor); + tracking.update(&ss.sketch, &ss.plane, proj, cursor, points); let shown = tracking.guide.is_some() || tracking.remembered_guide.is_some(); crate::scenario::publish_count("sketch_tracking_guides", usize::from(shown) as f64); let tk = Tokens::get(); @@ -451,29 +517,85 @@ mod tests { let to = |p: Vec2| proj.to_screen(Plane::XY.to_world(p)).unwrap(); let mut tracking = Tracking::default(); let centre = Vec2::new(-10.0, 3.0); - tracking.update(&sk, &Plane::XY, &proj, to(centre)); + tracking.update(&sk, &Plane::XY, &proj, to(centre), false); assert!(tracking.guide.is_none(), "no zero-length guide at the centre"); for end in [Vec2::new(35.0, 3.0), Vec2::new(-35.0, 3.0), Vec2::new(-10.0, 38.0), Vec2::new(-10.0, -38.0)] { - tracking.update(&sk, &Plane::XY, &proj, to(end)); + tracking.update(&sk, &Plane::XY, &proj, to(end), false); let (a, b) = tracking.guide.expect("all four directions"); assert!(a.dist(centre) < 1e-4 && b.dist(end) < 1e-4); } let end = Vec2::new(35.0, 3.0); - tracking.update(&sk, &Plane::XY, &proj, to(end) + egui::vec2(0.0, 5.0)); + tracking.update(&sk, &Plane::XY, &proj, to(end) + egui::vec2(0.0, 5.0), false); assert!(tracking.guide.is_some(), "5 screen points from the axis"); - tracking.update(&sk, &Plane::XY, &proj, to(end) + egui::vec2(0.0, 7.0)); + tracking.update(&sk, &Plane::XY, &proj, to(end) + egui::vec2(0.0, 7.0), false); assert!(tracking.guide.is_none(), "7 screen points from the axis"); let mid = Vec2::new(20.0, -20.0); - tracking.update(&sk, &Plane::XY, &proj, to(mid)); - tracking.update(&sk, &Plane::XY, &proj, to(Vec2::new(20.0, 20.0))); + tracking.update(&sk, &Plane::XY, &proj, to(mid), false); + tracking.update(&sk, &Plane::XY, &proj, to(Vec2::new(20.0, 20.0)), false); assert!(tracking.guide.unwrap().0.dist(mid) < 1e-8, "new MID replaces centre"); sk.curves.iter_mut().find(|c| c.id == "l").unwrap().id = "renamed".into(); - tracking.update(&sk, &Plane::XY, &proj, to(Vec2::new(20.0, 20.0))); + tracking.update(&sk, &Plane::XY, &proj, to(Vec2::new(20.0, 20.0)), false); assert!(tracking.guide.is_none(), "removed references cannot leave a stale guide"); sk.curves.iter_mut().find(|c| c.id == "renamed").unwrap().id = "l".into(); } } + #[test] + fn end_points_corners_and_free_points_start_guides_only_while_drawing() { + use solidcraft_engine::geom::Plane; + let mut app = SolveApp::new(Default::default(), Default::default()); + app.cam.yaw = 0.0; + app.cam.pitch = std::f64::consts::FRAC_PI_2; + app.cam.distance = 180.0; + let mut sk = Sketch::new(); + sk.add_line(Vec2::new(-30.0, 20.0), Vec2::new(30.0, 20.0), None, None, Some("top")).unwrap(); + sk.add_line(Vec2::new(30.0, 20.0), Vec2::new(30.0, -20.0), None, None, Some("right")).unwrap(); + sk.add_arc(Vec2::new(-20.0, -10.0), Vec2::new(-10.0, -10.0), Vec2::new(-20.0, 0.0), [None; 3], Some("arc")).unwrap(); + sk.add_point(Vec2::new(15.0, -25.0), Some("free")).unwrap(); + let hidden = sk.add_line(Vec2::new(45.0, 35.0), Vec2::new(55.0, 35.0), None, None, Some("hidden")).unwrap(); + sk.curves.get_mut(hidden).unwrap().construction = true; + sk.view.hide_construction = true; + let proj = crate::viewport::projection(&app, egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0))); + let to = |p: Vec2| proj.to_screen(Plane::XY.to_world(p)).unwrap(); + // Each point, then a cursor level with it far to the right: the guide runs from the point. + for (point, end) in [ + (Vec2::new(30.0, 20.0), Vec2::new(70.0, 20.0)), // a corner two lines share + (Vec2::new(-30.0, 20.0), Vec2::new(-30.0, -45.0)), // a line end, plumb below it + (Vec2::new(-20.0, 0.0), Vec2::new(60.0, 0.0)), // an arc end + (Vec2::new(15.0, -25.0), Vec2::new(15.0, 45.0)), // a free point + ] { + let mut drawing = Tracking::default(); + drawing.update(&sk, &Plane::XY, &proj, to(point), true); + drawing.update(&sk, &Plane::XY, &proj, to(end), true); + let (a, b) = drawing.guide.unwrap_or_else(|| panic!("a guide from {point:?}")); + assert!(a.dist(point) < 1e-9 && b.dist(end) < 1e-4, "{a:?} {b:?}"); + let mut idle = Tracking::default(); + idle.update(&sk, &Plane::XY, &proj, to(point), false); + idle.update(&sk, &Plane::XY, &proj, to(end), false); + assert!(idle.guide.is_none(), "idle hovering takes no point {point:?}"); + } + // The origin (the axes run through it) and the ends of a hidden curve start nothing. + for point in [Vec2::ZERO, Vec2::new(45.0, 35.0)] { + let mut tracking = Tracking::default(); + tracking.update(&sk, &Plane::XY, &proj, to(point), true); + assert!(tracking.anchor.is_none(), "{point:?}"); + } + // A centre or midpoint still names its curve; a point and a midpoint meet as two + // centres do, and leaving the drawing tool lets the point go but keeps the midpoint. + let mut tracking = Tracking::default(); + tracking.update(&sk, &Plane::XY, &proj, to(Vec2::new(-20.0, -10.0)), true); + assert_eq!(tracking.anchor, Some(TrackRef::Curve("arc".into()))); + tracking.update(&sk, &Plane::XY, &proj, to(Vec2::new(30.0, 0.0)), true); + tracking.update(&sk, &Plane::XY, &proj, to(Vec2::new(15.0, -25.0)), true); + assert_eq!(tracking.anchor, Some(TrackRef::Point("free".into()))); + assert_eq!(tracking.previous, Some(TrackRef::Curve("right".into()))); + let cross = Vec2::new(15.0, 0.0); + assert!(tracking.meeting(&sk, &Plane::XY, &proj, to(cross)).unwrap().dist(cross) < 1e-9); + tracking.update(&sk, &Plane::XY, &proj, to(Vec2::new(60.0, 0.0)), false); + assert_eq!(tracking.anchor, Some(TrackRef::Curve("right".into()))); + assert!(tracking.previous.is_none()); + } + #[test] fn two_recent_references_meet_and_a_third_drops_the_oldest() { use solidcraft_engine::geom::Plane; @@ -489,13 +611,13 @@ mod tests { let proj = crate::viewport::projection(&app, egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0))); let to = |p: Vec2| proj.to_screen(Plane::XY.to_world(p)).unwrap(); let mut tracking = Tracking::default(); - let hover = |tracking: &mut Tracking, p: Vec2| tracking.update(&sk, &Plane::XY, &proj, to(p)); + let hover = |tracking: &mut Tracking, p: Vec2| tracking.update(&sk, &Plane::XY, &proj, to(p), false); hover(&mut tracking, Vec2::new(0.0, 20.0)); hover(&mut tracking, Vec2::new(30.0, 0.0)); let centre = Vec2::ZERO; let near = to(centre) + egui::vec2(4.0, 3.0); assert!(tracking.meeting(&sk, &Plane::XY, &proj, near).unwrap().dist(centre) < 1e-6); - tracking.update(&sk, &Plane::XY, &proj, near); + tracking.update(&sk, &Plane::XY, &proj, near, false); let (a, b) = tracking.guide.unwrap(); let (c, d) = tracking.remembered_guide.unwrap(); assert!(b.dist(centre) < 1e-6 && d.dist(centre) < 1e-6, "both guides end on the crossing"); @@ -503,8 +625,8 @@ mod tests { assert!(tracking.meeting(&sk, &Plane::XY, &proj, to(centre) + egui::vec2(20.0, 0.0)).is_none()); // Back to the first reference: both stay, in swapped order. hover(&mut tracking, Vec2::new(0.0, 20.0)); - assert_eq!(tracking.anchor.as_deref(), Some("top")); - assert_eq!(tracking.previous.as_deref(), Some("right")); + assert_eq!(tracking.anchor, Some(TrackRef::Curve("top".into()))); + assert_eq!(tracking.previous, Some(TrackRef::Curve("right".into()))); // A circle centre and a midpoint meet the same way. hover(&mut tracking, Vec2::new(-10.0, 3.0)); let cross = Vec2::new(0.0, 3.0); @@ -513,12 +635,12 @@ mod tests { // so their axes do not invent the rectangle centre. hover(&mut tracking, Vec2::new(30.0, 0.0)); hover(&mut tracking, Vec2::new(50.0, 0.0)); - assert_eq!(tracking.anchor.as_deref(), Some("far")); - assert_eq!(tracking.previous.as_deref(), Some("right")); + assert_eq!(tracking.anchor, Some(TrackRef::Curve("far".into()))); + assert_eq!(tracking.previous, Some(TrackRef::Curve("right".into()))); assert!(tracking.meeting(&sk, &Plane::XY, &proj, to(centre)).is_none()); sk.curves.iter_mut().find(|c| c.id == "far").unwrap().id = "gone".into(); - tracking.update(&sk, &Plane::XY, &proj, to(Vec2::new(40.0, 40.0))); - assert_eq!(tracking.anchor.as_deref(), Some("right")); + tracking.update(&sk, &Plane::XY, &proj, to(Vec2::new(40.0, 40.0)), false); + assert_eq!(tracking.anchor, Some(TrackRef::Curve("right".into()))); assert!(tracking.previous.is_none()); assert!(tracking.guide.is_none(), "a removed reference leaves no guide"); } diff --git a/crates/ui-egui/src/issue_report.rs b/crates/ui-egui/src/issue_report.rs index 7af85281f5..69344ed8c4 100644 --- a/crates/ui-egui/src/issue_report.rs +++ b/crates/ui-egui/src/issue_report.rs @@ -1532,6 +1532,32 @@ mod tests { } } + /// #9916: the window opens at the top middle, and dragging its title band moves it off the + /// model, in a sketch too. + #[test] + fn issue_report_window_moves_by_its_title() { + for sketch in [false, true] { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.call("ui.resize", json!({"width":1400,"height":800})); + if sketch { + h.app.run("sketch.create", json!({"plane": "XY"})).unwrap(); + } + h.app.issue_report.draft = Some(draft_for(&h.app, None, None)); + h.frames(10); + let rect = |h: &crate::scenario::Harness| egui::containers::AreaState::load(&h.ctx, egui::Id::new("sc_issue_report")).unwrap().rect(); + let at = rect(&h); + assert!((at.center().x - h.ctx.content_rect().center().x).abs() < 1.0, "sketch={sketch}: it opens at the top middle: {at:?}"); + // On the title, clear of the close button. + let grip = at.left_top() + egui::vec2(140.0, 18.0); + h.call("ui.drag", json!({"x0": grip.x, "y0": grip.y, "x1": grip.x - 250.0, "y1": grip.y, "steps": 20})); + h.frames(4); + let moved = rect(&h); + assert!((moved.min.x - (at.min.x - 250.0)).abs() < 2.0 && (moved.min.y - at.min.y).abs() < 2.0, "sketch={sketch}: {at:?} -> {moved:?}"); + assert!(h.app.issue_report.draft.is_some(), "still open"); + } + } + /// Save screenshot… writes the PNG where the picker says, the way the web app's other saves /// write (#7192), and a failure reads as plain words, not a Rust value. #[test] diff --git a/crates/ui-egui/src/issue_report/form.rs b/crates/ui-egui/src/issue_report/form.rs index a2b86f4900..3cb0ff5c42 100644 --- a/crates/ui-egui/src/issue_report/form.rs +++ b/crates/ui-egui/src/issue_report/form.rs @@ -120,7 +120,9 @@ pub(super) fn show(app: &mut SolveApp, draft: &mut Draft, ctx: &egui::Context) - let close_label = if compact { "" } else { "Close draft" }; let (closed, mut action) = ui_kit::Window::new(Id::new("sc_issue_report"), "bug_report", tr_label("Report a bug")) .cards(cards) - .anchor(egui::Align2::CENTER_TOP, vec2(0.0, t.space_16)) + // Opens at the top middle, and its title band drags it off what the report is about + // (#9916). + .placed(egui::Align2::CENTER_TOP, egui::pos2(screen.center().x, screen.top() + t.space_16)) .fields() .keep_open_on_escape() .actions(tr_label(open_label), tr_label(close_label), Some(tr_label("Copy report"))) diff --git a/crates/ui-egui/src/joint_browser_tests.rs b/crates/ui-egui/src/joint_browser_tests.rs index 15f07c5053..d88f00a9ae 100644 --- a/crates/ui-egui/src/joint_browser_tests.rs +++ b/crates/ui-egui/src/joint_browser_tests.rs @@ -109,6 +109,66 @@ fn the_menu_and_the_v_key_hide_and_show_picked_joints() { assert!(h.app.session.joint_visible(hinge) && h.app.session.joint_visible(glue)); } +/// Each placement has a Joints folder listing the joints that attach it, beside the placement +/// each joins it to (#9130). Its rows are the root folder's joints: a click picks, the eye +/// hides, a double-click edits and the menu acts on the same joint. +#[test] +fn each_placement_lists_the_joints_that_attach_it() { + let mut h = harness(); + let hinge = id(&h, "Hinge"); + let occ = |h: &Harness, name: &str| h.app.session.doc.occurrences.iter().find(|o| o.name == name).unwrap().id; + let (a, b, c) = (occ(&h, "A:1"), occ(&h, "B:1"), occ(&h, "C:1")); + let listed = |h: &Harness, o: u64| -> Vec<(String, String)> { + crate::dialogs_assembly::placement_joints(&h.app.session.doc, &[o]).into_iter().map(|j| (j.name, j.other)).collect() + }; + let pair = |n: &str, o: &str| (n.to_string(), o.to_string()); + assert_eq!(listed(&h, a), [pair("Hinge", "B:1"), pair("Glue", "C:1")]); + assert_eq!(listed(&h, b), [pair("Hinge", "A:1")]); + assert_eq!(listed(&h, c), [pair("Glue", "A:1")]); + + // The folders start closed: only the root's lists rows. + let rows = |label: &str| crate::browser::drawn_rows().into_iter().filter(|r| r.0 == label).map(|r| r.1.center().y).collect::>(); + assert_eq!(rows("Joints").len(), 4, "the root's Joints folder and one per placement"); + assert_eq!(rows("Hinge").len(), 1); + + // Opened, B's folder lists Hinge a second time, below the root's. + h.app.tree.expanded.insert(format!("o:{b}/joints")); + h.frames(3); + let hinges = rows("Hinge"); + assert_eq!(hinges.len(), 2); + assert_eq!(rows("Glue").len(), 1, "Glue doesn't attach B"); + let at = crate::scenario::handle_at("row:Hinge").unwrap(); + assert_eq!(at.y, hinges[1], "the row clicked below is B's"); + + h.step(&json!({"click": {"handle": "row:Hinge"}, "double": true})).unwrap(); + h.frames(3); + let editing = match h.app.dialog.as_ref().map(|d| &d.kind) { + Some(crate::dialogs::Kind::Assembly(crate::dialogs_assembly::Asm::Joint(f))) => f.editing, + _ => None, + }; + assert_eq!(editing, Some(hinge), "a double click edits the joint"); + h.step(&json!({"key": "Escape"})).unwrap(); + h.frames(3); + assert!(h.app.dialog.is_none()); + + h.app.tree.picked_joints.clear(); + click(&mut h, "row:Hinge"); + assert_eq!(h.app.tree.picked_joints, [hinge]); + click(&mut h, "eye:Hinge"); + assert!(!h.app.session.joint_visible(hinge), "B's eye hides the joint itself"); + click(&mut h, "eye:Hinge"); + assert!(h.app.session.joint_visible(hinge)); + + // Its menu is the joint's: Delete removes Hinge, and B, which nothing joins now, has no folder. + h.step(&json!({"click": {"handle": "row:Hinge"}, "button": "right"})).unwrap(); + h.frames(3); + h.step(&json!({"widget": "Delete"})).unwrap(); + h.frames(3); + assert!(h.app.session.doc.assembly.joints.iter().all(|j| j.id != hinge)); + assert!(listed(&h, b).is_empty()); + assert_eq!(rows("Joints").len(), 3, "the root's, A's and C's"); +} + mod delete { //! Joint history and Browser selection must be the target of the Delete key. use serde_json::{Value, json}; diff --git a/crates/ui-egui/src/keymap.rs b/crates/ui-egui/src/keymap.rs index 990236e939..e7de0778cd 100644 --- a/crates/ui-egui/src/keymap.rs +++ b/crates/ui-egui/src/keymap.rs @@ -185,13 +185,6 @@ impl Keymap { } pub fn load(&mut self, v: &Value) { self.custom = v.as_object().map(|o| o.iter().take(2000).filter_map(|(k, v)| Some((k.clone(), saved_keys(v)?))).collect()).unwrap_or_default(); - // A shortcut that named a modifier key twice ("Shift+ShiftLeft", before shift - // keys were named after themselves) is the modifier itself (#4582). - for key in self.custom.values_mut().flatten() { - if let Some(named) = named_modifier_key(key) { - *key = named; - } - } } } @@ -206,12 +199,70 @@ pub(crate) fn keys_value(keys: &[String]) -> Value { /// A command's saved keys: `""` (none), one key, or a list of them. pub(crate) fn saved_keys(v: &Value) -> Option> { - match v { - Value::String(key) if key.is_empty() => Some(Vec::new()), - Value::String(key) => Some(vec![key.clone()]), - Value::Array(keys) => Some(keys.iter().filter_map(Value::as_str).filter(|k| !k.is_empty()).take(MAX_KEYS).map(str::to_string).collect()), - _ => None, + let raw: Vec<&str> = match v { + Value::String(key) => vec![key.as_str()], + Value::Array(keys) => keys.iter().filter_map(Value::as_str).filter(|k| !k.is_empty()).take(MAX_KEYS).collect(), + _ => return None, + }; + if raw.iter().any(|key| key.len() > 256) { + log::warn!("saved shortcut name exceeds 256 bytes; binding ignored"); + return None; } + let mut keys = Vec::new(); + for key in raw.into_iter().filter(|key| !key.is_empty()) { + let Some(key) = canonical_key(key).filter(|key| !RESERVED.contains(&key.as_str())) else { + log::warn!("invalid or reserved saved shortcut ignored"); + continue; + }; + if !keys.contains(&key) { + keys.push(key); + } + } + Some(keys) +} + +/// Read the same chord that recording a key produces, independent of case and modifier order. +/// The last token is the key; the preceding tokens are modifiers. Keep the historical primary +/// Ctrl/Cmd alias, explicit Control+Command chords and old physical modifier-key names. +fn canonical_key(text: &str) -> Option { + if text.len() > 256 { + return None; + } + let text = text.trim(); + if text.is_empty() || (text.starts_with('+') && text != "+") { + return None; + } + let (prefix, name) = if text == "+" { + ("", "+") + } else if let Some(prefix) = text.strip_suffix("++") { + (prefix, "+") + } else { + text.rsplit_once('+').unwrap_or(("", text)) + }; + let mut mods = egui::Modifiers::NONE; + if !prefix.is_empty() { + for part in prefix.split('+') { + match part.trim().to_ascii_lowercase().as_str() { + "ctrl" | "control" => mods.ctrl = true, + "cmd" | "command" => mods.mac_cmd = true, + "alt" | "option" => mods.alt = true, + "shift" => mods.shift = true, + _ => return None, + } + } + } + mods.command = mods.ctrl || mods.mac_cmd; + let name = name.trim(); + let key = match name.to_ascii_lowercase().as_str() { + "ctrl" | "control" if !mods.ctrl && !mods.mac_cmd => egui::Key::ControlLeft, + "cmd" | "command" if !mods.ctrl && !mods.mac_cmd => egui::Key::SuperLeft, + "alt" | "option" if !mods.alt => egui::Key::AltLeft, + "shift" if !mods.shift => egui::Key::ShiftLeft, + "esc" => egui::Key::Escape, + "return" => egui::Key::Enter, + _ => egui::Key::ALL.iter().copied().find(|key| key.name().eq_ignore_ascii_case(name)).or_else(|| egui::Key::from_name(name))?, + }; + Some(key_name(key, mods)) } /// A key press as a shortcut name: `E`, `Shift+E`, `Ctrl+Alt+K`, `F5`, `Delete`. @@ -268,16 +319,6 @@ impl Modifier { } } - /// The modifier a key is called in a shortcut name. - fn of_name(name: &str) -> Option { - match name { - "ShiftLeft" | "ShiftRight" => Some(Modifier::Shift), - "ControlLeft" | "ControlRight" | "SuperLeft" | "SuperRight" => Some(Modifier::Ctrl), - "AltLeft" | "AltRight" => Some(Modifier::Alt), - _ => None, - } - } - /// The modifier's name in a shortcut. fn name(self) -> &'static str { match self { @@ -288,21 +329,6 @@ impl Modifier { } } -/// A shortcut name that ends in a modifier key, with that key named after its modifier: -/// `Shift+ShiftLeft` is `Shift` and `Ctrl+ShiftLeft` is `Ctrl+Shift` (#4582). -fn named_modifier_key(key: &str) -> Option { - let mut parts: Vec<&str> = key.split('+').collect(); - let last = parts.pop()?; - let modifier = Modifier::of_name(last)?; - let name = modifier.name(); - // The modifier named twice ("Shift+Shift") is the modifier alone. - if parts.last() == Some(&name) { - parts.pop(); - } - parts.push(name); - Some(parts.join("+")) -} - /// The key a command runs from now, the one menus and tooltips show: its first. pub fn effective(app: &SolveApp, id: &str) -> Option { keys(app, id).into_iter().next() @@ -461,6 +487,8 @@ pub fn bind(app: &mut SolveApp, id: &str, key: &str, replace: bool) -> Result<() /// sketch tool and a model command may share a key: each runs in its own mode. pub fn bind_at(app: &mut SolveApp, id: &str, slot: Option, key: &str, replace: bool) -> Result<(), String> { let spec = solidcraft_engine::find_command(id).ok_or_else(|| format!("no command `{id}`"))?; + let canonical = if key.is_empty() { String::new() } else { canonical_key(key).ok_or("unknown shortcut key")? }; + let key = canonical.as_str(); if RESERVED.contains(&key) { return Err(format!("{key} is used by SolidCraft itself")); } @@ -520,10 +548,15 @@ pub fn reset(app: &mut SolveApp, id: Option<&str>) { pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) { let ctx = ui.ctx().clone(); let filtering = crate::prefs_search::querying(ui); + // A Preferences search lists the matching shortcuts too, and one recorded from there takes + // the key like one on this page (#10043). Only a search field that has the keyboard keeps + // its keys: going back to it to type stops the recording. + if ctx.memory(|m| m.has_focus(egui::Id::new("prefs_search"))) && app.keymap.capturing.is_some() { + stop_capture(app); + } // Keep a pending chord until it has settled and all keys are released. Modifier-only // input never starts the timer, so modifiers can be assembled at the user's own pace. - // A Preferences search is using the keyboard; don't swallow those keys to finish a capture. - if !filtering && let Some(id) = app.keymap.capturing.clone() { + if let Some(id) = app.keymap.capturing.clone() { match ctx.input(|i| app.keymap.recorder.update(i)) { Recording::Waiting => { if app.keymap.recorder.key.is_some() { @@ -599,20 +632,8 @@ pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) { }, )); } - if let Some(m) = &app.keymap.message { - ui_kit::text(ui, m, ui_kit::Text::Report("keymap_message", true)); - } - if let Some((_, key, _, _)) = app.keymap.conflict.clone() { - ui.horizontal_wrapped(|ui| { - let replace = ui_kit::button(ui, crate::i18n::format("keymap.use_anyway", &[("key", &display(&key))]), ui_kit::Button::Normal); - crate::scenario::publish_target("keymap:replace", replace.rect); - if replace.clicked() { - action = Some((String::new(), "replace")); - } - if ui_kit::button(ui, app.tr_label("Cancel"), ui_kit::Button::Normal).clicked() { - action = Some((String::new(), "cancel")); - } - }); + if let Some(chosen) = outcome(app, ui) { + action = Some((String::new(), chosen)); } ui_kit::text(ui, app.tr_label("Select Other: hold the left button still on the model (or right-click › Select Other…) to list everything under the cursor; click again on the same spot to select the next thing there. Double-click the middle button to go Home (Preferences › Navigation)."), ui_kit::Text::Note("navigation")); } @@ -674,6 +695,16 @@ pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) { ui_kit::Column::new("Keyboard Shortcuts", 1.0).flex().min_content_width(key_width), ui_kit::Column::new("", 0.0).icon_only().min_content_width(action_width), ]; + // A search shows a recording's outcome and its next step above the shortcuts it found. + if filtering { + if let Some(chosen) = outcome(app, ui) { + action = Some((String::new(), chosen)); + } + if app.keymap.capturing.is_some() { + let hint = ctx.input(|input| app.keymap.recorder.hint(input)); + ui_kit::text(ui, app.tr(hint), ui_kit::Text::Hint("shortcut_capture_state")); + } + } ui_kit::table(ui, "keymap", &columns, crate::i18n::tr("keymap.empty"), |ui, table| { for (c, keys, translated_label) in commands { table.row_visible(ui, c.id, app.keymap.capturing.as_deref() == Some(c.id), false, |row| { @@ -798,6 +829,28 @@ pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) { } } +/// A recording's outcome: its message, and for a key another command uses, Replace and Cancel +/// (the one chosen). +fn outcome(app: &SolveApp, ui: &mut egui::Ui) -> Option<&'static str> { + let mut chosen = None; + if let Some(m) = &app.keymap.message { + ui_kit::text(ui, m, ui_kit::Text::Report("keymap_message", true)); + } + if let Some((_, key, _, _)) = app.keymap.conflict.clone() { + ui.horizontal_wrapped(|ui| { + let replace = ui_kit::button(ui, crate::i18n::format("keymap.use_anyway", &[("key", &display(&key))]), ui_kit::Button::Normal); + crate::scenario::publish_target("keymap:replace", replace.rect); + if replace.clicked() { + chosen = Some("replace"); + } + if ui_kit::button(ui, app.tr_label("Cancel"), ui_kit::Button::Normal).clicked() { + chosen = Some("cancel"); + } + }); + } + chosen +} + /// Stop waiting for a key (the dialog closed). pub fn stop_capture(app: &mut SolveApp) { app.keymap.capturing = None; @@ -806,9 +859,13 @@ pub fn stop_capture(app: &mut SolveApp) { app.keymap.message = None; } -/// Is the page waiting for a key (so the key must not also run a command)? +/// Is the page waiting for a key (so the key must not also run a command)? On the Shortcuts page, +/// or in a Preferences search, which shows the matching shortcuts on the page it started from +/// (#10043). pub fn capturing(app: &SolveApp) -> bool { - app.prefs_window.open && app.prefs_window.section == crate::prefs::PAGE_SHORTCUTS && app.keymap.capturing.is_some() + app.prefs_window.open + && app.keymap.capturing.is_some() + && (app.prefs_window.section == crate::prefs::PAGE_SHORTCUTS || !app.prefs_window.filter.trim().is_empty()) } #[cfg(test)] @@ -819,6 +876,105 @@ mod tests { SolveApp::new(solidcraft_engine::Session::default(), Default::default()) } + #[test] + fn text_shortcuts_preserve_recorded_keys_and_modifier_aliases() { + for &key in egui::Key::ALL.iter().filter(|&&key| Modifier::of(key).is_none()) { + for flags in 0..16 { + let mods = egui::Modifiers { + ctrl: flags & 1 != 0, + mac_cmd: flags & 2 != 0, + command: flags & 3 != 0, + alt: flags & 4 != 0, + shift: flags & 8 != 0, + }; + let recorded = key_name(key, mods); + assert_eq!(canonical_key(&recorded), Some(recorded.clone()), "{recorded}"); + } + } + for (text, expected) in [ + (" shift + CTRL + shift + t ", "Ctrl+Shift+T"), + ("cmd+option+t", "Ctrl+Alt+T"), + ("shift+cmd+ctrl+t", "Ctrl+Cmd+Shift+T"), + ("ctrl++", "Ctrl+Plus"), + ("+", "Plus"), + ("Ctrl+ShiftLeft", "Ctrl+Shift"), + ("shift+shiftright", "Shift"), + ("shift", "Shift"), + ] { + assert_eq!(canonical_key(text).as_deref(), Some(expected), "{text}"); + } + for text in ["", " ", "+T", "++", "Ctrl+", "Ctrl++T", "Foo+T", "T+Ctrl", "Shift+Shift"] { + assert_eq!(canonical_key(text), None, "{text}"); + } + assert_eq!(canonical_key(&"T".repeat(257)), None); + } + + #[test] + fn text_shortcuts_use_the_recorded_chord_and_check_conflicts() { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + for spelling in ["ctrl+shift+t", "Shift+Ctrl+T", "CTRL+SHIFT+T", "Ctrl+Shift+T"] { + let reply = h.call("ui.shortcut", json!({"command":"solid.box", "key":spelling})); + assert_eq!(reply["result"]["keys"], json!(["Ctrl+Shift+T"]), "{reply}"); + h.call("ui.key", json!({"key":"T", "cmd":true, "shift":true})); + assert!( + matches!(h.app.dialog.as_ref().map(|d| &d.kind), Some(crate::dialogs::Kind::Primitive { cmd: "solid.box", .. })), + "{spelling} opens Box" + ); + h.call("ui.key", json!({"key":"Escape"})); + } + let before = h.app.keymap.prefs(); + let conflict = h.call("ui.shortcut", json!({"command":"solid.cylinder", "key":"shift+CTRL+t"})); + assert!(conflict["error"].as_str().unwrap().contains("already used by Box")); + for key in ["Foo", "Shift+Shift", "Ctrl+", "Ctrl++T", "T+Ctrl", "escape", "ctrl+z", "delete", "ctrl+comma"] { + let reply = h.call("ui.shortcut", json!({"command":"solid.box", "key":key})); + assert_eq!(reply["ok"], false, "{key}: {reply}"); + assert_eq!(h.app.keymap.prefs(), before, "a refused binding changes nothing"); + } + bind(&mut h.app, "solid.cylinder", "shift+ctrl+t", true).unwrap(); + assert!(keys(&h.app, "solid.box").is_empty()); + assert_eq!(keys(&h.app, "solid.cylinder"), ["Ctrl+Shift+T"]); + } + + #[test] + fn text_shortcuts_are_checked_in_settings_and_shortcut_imports() { + let saved = json!({ + "solid.box":["shift+ctrl+t", "CTRL+SHIFT+T", "Foo", "ctrl+z", "delete", "escape"], + "solid.cylinder":"alt+f12", + "solid.extrude":"Shift+ShiftLeft", + "solid.sphere":"" + }); + let mut a = app(); + a.keymap.load(&saved); + assert_eq!(keys(&a, "solid.box"), ["Ctrl+Shift+T"]); + assert_eq!(keys(&a, "solid.cylinder"), ["Alt+F12"]); + assert_eq!(keys(&a, "solid.extrude"), ["Shift"], "legacy modifier-only keys survive"); + assert!(keys(&a, "solid.sphere").is_empty()); + let mut imported = app(); + crate::toolbar_custom::import(&mut imported, &json!({"format":"solidcraft-shortcuts/1", "shortcuts":saved})).unwrap(); + assert_eq!(imported.keymap.prefs(), a.keymap.prefs()); + let mut h = crate::scenario::Harness::new(); + let profile = json!({"format":crate::settings_profiles::FORMAT, "name":"Text keys", "shortcuts":saved}); + for (command, params) in [("settings.profile.import", json!({"profile":profile})), ("settings.profile.apply", json!({"name":"Text keys"}))] { + let reply = h.call("engine.execute", json!({"command":command, "params":params})); + assert_eq!(reply["ok"], true, "{reply}"); + } + assert_eq!(h.app.keymap.prefs(), a.keymap.prefs()); + } + + #[test] + fn text_shortcuts_command_catalog_reports_the_current_binding() { + let mut h = crate::scenario::Harness::new(); + for key in ["Ctrl+Shift+F", ""] { + bind(&mut h.app, "solid.fillet", key, false).unwrap(); + for (method, params) in [("engine.commands", json!({})), ("engine.execute", json!({"command":"engine.commands", "params":{}}))] { + let reply = h.call(method, params); + let row = reply["result"].as_array().unwrap().iter().find(|row| row["id"] == "solid.fillet").unwrap(); + assert_eq!(row["shortcut"], json!(effective(&h.app, "solid.fillet")), "{method}"); + } + } + } + fn recorder_frame(a: &mut SolveApp, ctx: &egui::Context, time: f64, events: Vec) { let raw = egui::RawInput { time: Some(time), events, ..Default::default() }; let mut out = ctx.run_ui(raw, |ui| page(a, ui)); @@ -1035,6 +1191,47 @@ mod tests { assert!(h.app.prefs_window.filter.is_empty(), "a shortcut being recorded keeps the key"); } + /// A shortcut recorded from a Preferences search result takes the key, as on the Shortcuts + /// page (#10043): the search keeps its query, and the key runs no command behind + /// Preferences (P is Project's, which would open Create Sketch). P being Project's is + /// reported there with the choice to use it anyway, as on the page. Going back to the search + /// field to type stops a recording. + #[test] + fn a_shortcut_recorded_from_a_preferences_search_takes_the_key() { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.call("ui.prefs", json!({"open": "General"})); + h.frames(3); + h.step(&json!({"text": "fix"})).expect("search"); + h.frames(3); + h.step(&json!({"click": {"handle": "prefs:search:shortcuts"}})).expect("unfold the shortcut matches"); + h.frames(2); + h.step(&json!({"click": {"handle": "shortcut:sketch.constraint.fix"}})).expect("record Fix/UnFix's key"); + assert!(capturing(&h.app), "recording from a search result"); + h.step(&json!({"key": "P", "pressed": true})).unwrap(); + h.step(&json!({"text": "p"})).unwrap(); + h.step(&json!({"key": "P", "pressed": false})).unwrap(); + h.frames(65); + assert_eq!(h.app.prefs_window.filter, "fix", "the key went to the search"); + assert!(h.app.dialog.is_none() && h.app.session.active_sketch.is_none(), "the key ran a command"); + assert!(h.app.keymap.capturing.is_none()); + let conflict = h.app.keymap.conflict.as_ref().map(|(id, key, ..)| (id.as_str(), key.as_str())); + assert_eq!(conflict, Some(("sketch.constraint.fix", "P")), "{:?}", h.app.keymap.message); + // The search shows why, and the choice, above the shortcuts it found. + let message = h.app.keymap.message.clone().unwrap_or_default(); + assert!(h.widget(&message).is_some() && h.widget("Use P here anyway").is_some(), "{message}"); + h.step(&json!({"click": {"handle": "keymap:replace"}})).expect("use P anyway"); + assert_eq!(effective(&h.app, "sketch.constraint.fix").as_deref(), Some("P")); + + h.step(&json!({"click": {"handle": "shortcut:sketch.constraint.fix:1"}})).expect("record a second key"); + assert!(capturing(&h.app)); + h.step(&json!({"click": {"handle": "prefs:search"}})).expect("back to the search"); + h.step(&json!({"text": "x"})).unwrap(); + h.frames(2); + assert!(h.app.keymap.capturing.is_none(), "typing in the search stops the recording"); + assert_eq!(h.app.prefs_window.filter, "fixx"); + } + #[test] fn escape_cancels_only_recording_and_no_command_fires_during_capture() { let mut h = crate::scenario::Harness::new(); @@ -1178,7 +1375,7 @@ mod tests { assert!(keys(&b, "solid.extrude").is_empty()); assert_eq!(b.keymap.prefs()["solid.extrude"], json!("")); // A hostile settings file can't give one command endless keys. - let many: Vec = (0..50).map(|n| format!("Ctrl+Alt+F{n}")).collect(); + let many: Vec = (1..50).map(|n| format!("Ctrl+Alt+F{n}")).collect(); b.keymap.load(&json!({"solid.extrude": many})); assert_eq!(keys(&b, "solid.extrude").len(), MAX_KEYS); assert!(bind_at(&mut b, "solid.extrude", Some(MAX_KEYS), "Shift+L", false).is_err()); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a5082af89f..c3288e0b74 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -212,10 +212,12 @@ mod print_face_ui; pub(crate) mod quick_measure; #[cfg(test)] mod quick_measure_tests; +pub(crate) mod recent_colors; pub mod recovery_ui; pub mod ref_images; mod render_ui; pub mod render_workspace; +pub mod resume_ui; #[cfg(test)] mod revolve_edit_tests; #[cfg(test)] @@ -255,6 +257,7 @@ mod sketch_edit_tests; mod sketch_families; #[cfg(test)] mod sketch_guidance_ui_tests; +pub(crate) mod sketch_line_arc; pub mod sketch_modify_ui; #[cfg(test)] mod sketch_modify_ui_tests; @@ -285,6 +288,7 @@ mod sweep_dialog_tests; pub(crate) mod test_boat; pub mod theme; pub mod theme_files; +mod theme_options; #[cfg(test)] mod thin_extrude_tests; pub mod timeline; @@ -304,6 +308,7 @@ pub(crate) mod view_angles; mod view_confirm; pub mod view_pick; pub mod viewport; +mod viewport_details; mod viewport_layout; mod viewport_status; mod viewport_status_fixture; @@ -355,6 +360,9 @@ pub struct UiState { pub pick_bodies: bool, /// A face picks its exact component occurrence in Design and Render. pub pick_components: bool, + /// The selection shape (#4376): a drag in the view draws a freehand lasso that takes what is + /// wholly inside it, instead of the window/crossing rectangle. Off by default. + pub lasso_select: bool, /// 0: shaded with edges, 1: shaded, 2: wireframe, 3: shaded with hidden edges. pub visual_style: u8, /// A soft shadow on the ground under the model (seen from above). @@ -366,6 +374,8 @@ pub struct UiState { /// The running tool's icon rides beside the pointer in the viewport (View settings; off by /// default). pub tool_cursor: bool, + /// The colours picked last in any colour picker, newest first (#9234); kept between runs. + pub recent_colors: Vec<[u8; 3]>, } impl UiState { @@ -398,11 +408,13 @@ impl Default for UiState { pick_off: Vec::new(), pick_bodies: false, pick_components: false, + lasso_select: false, visual_style: 0, ground_shadow: true, up_axis: Default::default(), agent_cursor: Default::default(), tool_cursor: false, + recent_colors: Vec::new(), } } } @@ -503,6 +515,9 @@ pub struct SolveApp { /// The views around opening the open sketch. Leaving it goes back to the earlier one as the /// "When a sketch ends" preference says (#7038). pub pre_sketch_cam: Option, + /// The view from before Extrude turned it to isometric (#9176); the view goes back to it when + /// the dialog closes, as the "and back when it closes" preference says. + pub pre_extrude_cam: Option, /// The component active before Edit Sketch activated the one the sketch is in (#8127); /// leaving the sketch makes it active again. pub pre_sketch_component: Option, @@ -607,6 +622,9 @@ pub struct SolveApp { /// The bodies (or components) the last copy or cut took, with the design they were in, to /// paste into another design (`component.paste_from_design`). pub foreign_copy: Option>, + /// The sketch geometry the last copy or cut took, when it was sketch geometry: a sketch of + /// another design pastes it (#9258). + pub foreign_sketch_clip: Option, /// Esc was already acted on this frame (by the shortcuts), so later handlers skip it. pub esc_handled: bool, pub(crate) escape_owner: Option, @@ -697,7 +715,9 @@ pub struct SolveApp { shot_token: u64, styled: bool, fitted: bool, - pending_fit: Option<(Camera, solidcraft_engine::geom::Aabb3, bool)>, + /// A framing to settle once the viewport's layout is known: the camera to frame from, the + /// bounds, whether to glide there, and whether it is the navigators' shared-zoom framing. + pending_fit: Option<(Camera, solidcraft_engine::geom::Aabb3, bool, bool)>, /// Frames a pending fit has waited for a layout that can frame it (`resolve_fit`). fit_waits: u32, /// The sheet metal flat pattern's own pan and zoom. @@ -757,6 +777,7 @@ impl SolveApp { now: 0.0, pre_sketch_marker: None, pre_sketch_cam: None, + pre_extrude_cam: None, pre_sketch_component: None, services, file_picker: None, @@ -811,6 +832,7 @@ impl SolveApp { clipboard_stamp: 0, own_clipboard_stamp: 0, foreign_copy: None, + foreign_sketch_clip: None, esc_handled: false, escape_owner: None, last_escape: None, @@ -974,10 +996,11 @@ impl SolveApp { /// Preferences kept between runs (JSON), for the host to store. pub fn prefs(&self) -> String { - json!({ + let mut v = json!({ "dark": self.ui.dark, "grid": self.ui.show_grid, "navigation_bar": self.ui.show_navigation_bar, + "tool_cursor": self.ui.tool_cursor, "perspective": self.ui.perspective, "auto_ortho_faces": self.ui.auto_ortho_faces, "pick_bodies": self.ui.pick_bodies, @@ -995,10 +1018,22 @@ impl SolveApp { "autosave_minutes": self.autosave_minutes, "ground_shadow": self.ui.ground_shadow, "agent_cursor": self.ui.agent_cursor, + "recent_colors": self.ui.recent_colors, "preferences": self.preferences, "gpu_path_setup_seconds": self.render_workspace.setup_seconds, - }) - .to_string() + }); + // While the Preferences dialog is open the file holds what Cancel would restore: a change + // that was never applied must not reach disk through the periodic save, a quit or a + // window close (#9730). One change covers every writer — desktop save and on_exit, the + // web app's save, and the control channel. + if let Some(held) = prefs::held_overlay(self) + && let Some(obj) = v.as_object_mut() + { + for (key, value) in held { + obj.insert(key, value); + } + } + v.to_string() } /// Restore preferences saved with [`SolveApp::prefs`]. @@ -1018,6 +1053,9 @@ impl SolveApp { if let Some(visible) = flag("navigation_bar") { self.ui.show_navigation_bar = visible; } + if let Some(visible) = flag("tool_cursor") { + self.ui.tool_cursor = visible; + } if let Some(p) = flag("perspective") { self.ui.perspective = p; } @@ -1085,6 +1123,12 @@ impl SolveApp { if let Some(a) = v.get("agent_cursor").and_then(|a| serde_json::from_value(a.clone()).ok()) { self.ui.agent_cursor = a; } + // Colours that do not read (not three bytes) are left out; the list is capped. + self.ui.recent_colors = v + .get("recent_colors") + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(|c| serde_json::from_value::<[u8; 3]>(c.clone()).ok()).take(recent_colors::MAX_RECENT_COLORS).collect()) + .unwrap_or_default(); } pub fn with_control(mut self, rx: Receiver) -> Self { @@ -1331,9 +1375,7 @@ impl SolveApp { self.cancel_view_animation(); self.cam = to; } else if v.get("fit").and_then(Value::as_bool).unwrap_or(false) { - let bounds = solidcraft_engine::view::bounds(&self.session); - self.animate_to(self.fitted(to)); - self.pending_fit = Some((to, bounds, true)); + self.animate_to_fitted(to); } else { self.animate_to(to); } @@ -1510,6 +1552,16 @@ impl SolveApp { self.repeat_last(); return; } + // Modify ▸ Delete deletes what is selected, as the Delete key does (#9079); with nothing + // selected it says what to pick instead of running with no features. + if id == "timeline.delete" { + if delete::nothing_to_delete(self) && self.tree.picked_timeline_groups.is_empty() { + self.set_status(self.tr("ui.delete_select_first"), false); + } else if !timeline_left::delete_selected_folders(self) { + delete::delete_selection(self); + } + return; + } // Front … Isometric (Ctrl+1…7 by default) turn the view as the view cube's faces do. The // flat pattern is a 2D page: it has no other side to turn to. if solidcraft_engine::standard_view(id).is_some() { @@ -1771,6 +1823,10 @@ impl SolveApp { design_properties::open_selected(self); return; } + if id == "sketch.edit" { + self.start_sketch_edit(opener); + return; + } if id == "sketch.finish" { self.finish_sketch(); return; @@ -1921,7 +1977,11 @@ impl SolveApp { if d.wants_picks() && !self.session.selection.is_empty() { let _ = self.session.execute("select.clear", &json!({})); } + let extrude = matches!(d.kind, dialogs::Kind::Extrude { .. }); self.dialog = Some(d); + if extrude { + self.turn_for_extrude(); + } return; } // Undo and redo can recompute much of the history: off the window thread when slow. @@ -1942,6 +2002,37 @@ impl SolveApp { tools::Tool::for_command(id).is_some() || dialogs::Dialog::for_command(self, id).is_some() } + /// The ribbon has no sketch argument: use one unambiguous sketch pick, otherwise ask. + fn start_sketch_edit(&mut self, opener: Option) { + use solidcraft_engine::{Sel, doc::FeatureKind}; + let mut sketches: Vec = self + .session + .selection + .iter() + .filter_map(|s| match s { + Sel::Feature { id } => Some(*id), + Sel::Profile { sketch, .. } | Sel::ExternalSketchCurve { sketch, .. } | Sel::ExternalSketchPoint { sketch, .. } => Some(*sketch), + Sel::SketchCurve { sketch, .. } => *sketch, + _ => None, + }) + .filter(|id| self.session.doc.feature(*id).is_some_and(|f| matches!(f.kind, FeatureKind::Sketch { .. }))) + .collect(); + sketches.sort_unstable(); + sketches.dedup(); + let sketch = match sketches.as_slice() { + [id] => Some(*id), + [] => self.session.active_sketch, + _ => None, + }; + if let Some(id) = sketch { + self.edit_feature(id); + } else { + dialogs::cancel(self); + self.tool = None; + self.open_choice_menu(opener, context_menu::Target::EditSketch); + } + } + /// Edit a feature: sketches open in sketch mode; other features roll the /// timeline back to just before themselves and reopen their dialog, filled in. pub fn edit_feature(&mut self, id: u64) { @@ -2059,6 +2150,30 @@ impl SolveApp { turn } + /// Extrude opened: turn the view to isometric when Preferences say so, and remember the view + /// from before for when the dialog closes (#9176). + fn turn_for_extrude(&mut self) { + if !self.preferences.extrude_view_isometric { + return; + } + let before = self.settled_cam(); + let mut to = before; + to.set_view(StandardView::Iso); + self.pre_extrude_cam = self.preferences.extrude_view_return.then_some(before); + self.animate_to(to); + } + + /// Once Extrude has closed, however it closed, the view goes back to where it was before + /// Extrude turned it (#9176). Going back keeps the projection, as at the end of a sketch. + fn return_from_extrude_view(&mut self) { + if self.dialog.as_ref().is_some_and(|d| matches!(d.kind, dialogs::Kind::Extrude { .. })) { + return; + } + if let Some(before) = self.pre_extrude_cam.take() { + self.animate_to(Camera { fov: self.settled_cam().fov, ..before }); + } + } + /// Where the camera is going: the end of the running camera animation, else where it is. pub(crate) fn settled_cam(&self) -> Camera { self.cam_anim.map_or(self.cam, |a| a.to) @@ -2287,16 +2402,19 @@ impl SolveApp { self.cam_anim = None; self.viewport.orbit_free = None; let bounds = viewport::view_bounds(self); - self.pending_fit = Some((self.cam, bounds, false)); + // Framed as the Home button frames: with the navigators' Fit on, that is their shared + // zoom and centre, so a design opens the way Home shows it (#10091). + let shared = self.preferences.navigator_fit; + self.pending_fit = Some((self.cam, bounds, false, shared)); self.fit_waits = 0; - self.cam = self.fitted(self.cam); + self.cam = if shared { self.navigator_fitted(self.cam) } else { self.fitted(self.cam) }; } /// Frame `bounds` at once, keeping the view direction (programmatic use). pub fn fit_bounds(&mut self, bounds: solidcraft_engine::geom::Aabb3) { self.cancel_view_animation(); self.viewport.orbit_free = None; - self.pending_fit = Some((self.cam, bounds, false)); + self.pending_fit = Some((self.cam, bounds, false, false)); self.fit_waits = 0; self.cam = self.fitted_bounds(self.cam, &bounds); } @@ -2321,7 +2439,36 @@ impl SolveApp { if render_workspace::active(self) { return viewport_layout::fit_product_bounds(cam, bounds, &render_workspace::picture_layout(self, layout)); } - viewport_layout::fit_bounds(cam, bounds, layout, self.view_fov()) + viewport_layout::fit_bounds_sized(cam, bounds, layout, self.view_fov(), f64::from(self.preferences.home_fit_size)) + } + + /// `cam` framing the whole model the navigators' way (#10091): looking at the middle of the + /// model from the distance that fits its bounding sphere in the free part of the view, at + /// the Fit size. The sphere looks the same from every side, so Home and every face, + /// corner, arrow or axis clicked after it keep one zoom and one centre; only the direction + /// changes. + fn navigator_fitted(&self, cam: Camera) -> Camera { + let bounds = viewport::view_bounds(self); + self.navigator_fitted_bounds(cam, &bounds).unwrap_or_else(|| self.fitted(cam)) + } + + /// [`Self::navigator_fitted`] for `bounds`, or `None` when the layout can't frame it yet. + fn navigator_fitted_bounds(&self, mut cam: Camera, bounds: &solidcraft_engine::geom::Aabb3) -> Option { + if bounds.is_empty() || !bounds.min.is_finite() || !bounds.max.is_finite() { + return None; + } + let centre = bounds.center(); + let radius = (bounds.diagonal() * 0.5).max(0.5); + // A cube around the sphere, seen square-on from any axis, projects to the sphere's own + // square, so the layout fit of that cube is the sphere's. + let offset = solidcraft_engine::geom::Vec3::new(radius, radius, radius); + let sphere = solidcraft_engine::geom::Aabb3 { min: centre - offset, max: centre + offset }; + let mut probe = Camera::default(); + probe.set_view(StandardView::Front); + let framed = self.try_fitted_bounds(probe, &sphere)?; + cam.target = centre; + cam.distance = framed.distance; + cam.is_valid().then_some(cam) } fn fitted_bounds(&self, mut cam: Camera, bounds: &solidcraft_engine::geom::Aabb3) -> Camera { @@ -2339,11 +2486,17 @@ impl SolveApp { fn resolve_fit(&mut self, ctx: &egui::Context) { viewport::refresh_layout(self, ctx); if self.viewport.layout.is_some() - && let Some((cam, bounds, animate)) = self.pending_fit.take() + && let Some((cam, bounds, animate, shared)) = self.pending_fit.take() { // Nothing to frame: the camera goes to the empty design's own distance. let want = if bounds.is_empty() { viewport::view_bounds(self) } else { bounds }; - let to = if want.is_empty() { Some(self.fitted(cam)) } else { self.try_fitted_bounds(cam, &want) }; + let to = if want.is_empty() { + Some(self.fitted(cam)) + } else if shared { + self.navigator_fitted_bounds(cam, &want) + } else { + self.try_fitted_bounds(cam, &want) + }; let Some(to) = to else { // The viewport hasn't been drawn since the fit was asked for (the Home screen was // over it, as when a sample or a file is opened from it), so framing it now would @@ -2352,7 +2505,7 @@ impl SolveApp { // viewport is drawn; give up only if that never comes. if self.fit_waits < Self::FIT_WAITS { self.fit_waits += 1; - self.pending_fit = Some((cam, bounds, animate)); + self.pending_fit = Some((cam, bounds, animate, shared)); } else { self.fit_waits = 0; self.cam = self.fitted_bounds(cam, &want); @@ -2408,12 +2561,33 @@ impl SolveApp { return; } self.pending_fit = None; - let request = Some((self.cam, viewport::view_bounds(self), true)); + let request = Some((self.cam, viewport::view_bounds(self), true, false)); let to = self.fitted(self.cam); self.animate_to(to); self.pending_fit = request; } + /// Move to `to` and frame the whole model from there at Home's zoom (animated): the + /// navigators' Home, faces, corners, arrows and axes (`navigator_fitted`). + pub fn animate_to_fitted(&mut self, to: Camera) { + let bounds = solidcraft_engine::view::bounds(&self.session); + let shared = self.preferences.navigator_fit; + let framed = if shared { self.navigator_fitted(to) } else { self.fitted(to) }; + self.animate_to(framed); + self.pending_fit = Some((to, bounds, true, shared)); + } + + /// A view chosen on a navigator (a cube face, corner or arrow, an axis tip): the view turns + /// in place, or frames the whole model from the new direction when Preferences › Navigation + /// asks for it (`navigator_fit`, #10091). + pub fn animate_turn(&mut self, to: Camera) { + if self.preferences.navigator_fit { + self.animate_to_fitted(to); + } else { + self.animate_to(to); + } + } + /// Animated view changes for the axis view and navigation bar. pub fn animate_view(&mut self, v: &str) { if matches!(v, "fit" | "home") { @@ -2448,17 +2622,20 @@ impl SolveApp { let mut to = self.cam; if home { to.set_view(StandardView::Iso); - self.pending_fit = Some((to, viewport::view_bounds(self), true)); - to = self.fitted(to); + // With the navigators' Fit on, Home shares their zoom, so a face clicked after it + // keeps the zoom (#10091). + let shared = self.preferences.navigator_fit; + self.pending_fit = Some((to, viewport::view_bounds(self), true, shared)); + to = if shared { self.navigator_fitted(to) } else { self.fitted(to) }; } else { // With something selected, Fit frames the selection. match viewport::selection_bounds(self) { Some(bounds) => { - self.pending_fit = Some((to, bounds, true)); + self.pending_fit = Some((to, bounds, true, false)); to = self.fitted_bounds(to, &bounds); } None => { - self.pending_fit = Some((to, viewport::view_bounds(self), true)); + self.pending_fit = Some((to, viewport::view_bounds(self), true, false)); to = self.fitted(to); } } @@ -2518,6 +2695,12 @@ impl SolveApp { /// Per-frame logic before layout. pub fn logic(&mut self, ctx: &egui::Context) { + // Sleep/wake often skips frames without a device-lost signal; refresh egui GPU textures + // when the frame clock jumps so the chrome is not stuck blank (#8749). + let frame_time = ctx.input(|i| i.time); + if resume_ui::is_long_pause(self.now, frame_time) { + resume_ui::after_long_pause(ctx); + } frame_watch::begin(self); gpu::tick_tessellation(self); file_picker::tick(self, ctx); @@ -2556,6 +2739,7 @@ impl SolveApp { theme::apply(ctx); self.styled = true; } + theme::TabAccent::publish(self, ctx); if !self.fitted { self.fit_view(); self.fitted = true; @@ -2568,7 +2752,9 @@ impl SolveApp { self.poll_control_jobs(ctx); self.drain_control(ctx); self.follow_view_request(true); + self.return_from_extrude_view(); agent_cursor::step(self, ctx); + browser::eye_drag_frame(self, ctx); if !self.synthetic.is_empty() { ctx.request_repaint(); } @@ -2657,6 +2843,7 @@ impl SolveApp { pub fn ui(&mut self, ui: &mut egui::Ui) { value_step::set_enabled(ui.ctx(), self.preferences.step_values); canvas::set_boxes_shown(ui.ctx(), self.preferences.value_boxes_in_view); + recent_colors::share(ui.ctx(), &mut self.ui.recent_colors); let t0 = now_ms(); // The first frames draw with the bundled faces while the system's CJK, Hangul and Thai // faces are read on a thread; the fonts are installed again once they are there. @@ -2669,7 +2856,11 @@ impl SolveApp { self.applied_fonts_language.clone_from(&self.preferences.language); self.applied_ui_font.clone_from(&self.preferences.ui_font); self.applied_system_fonts = system_fonts; - crate::fonts::setup_fonts_without_waiting(ui.ctx(), &self.preferences.language, &self.preferences.ui_font); + crate::fonts::setup_fonts_without_waiting( + ui.ctx(), + crate::i18n::effective_language(&self.preferences.language), + &self.preferences.ui_font, + ); } let _language = crate::i18n::scope(&self.preferences.language); let mut report_input = None; @@ -2765,7 +2956,7 @@ impl SolveApp { self.shell.history = None; } // The model draws next: a dialog the toolbar closed last frame no longer shows its sketch. - dialogs::sync_feature_edit_sketch(self); + dialogs::sync_feature_edit(self); let full = ui.available_rect_before_wrap(); if floating { let cards = toolbar::shell_cards(self, full); diff --git a/crates/ui-egui/src/linked_source.rs b/crates/ui-egui/src/linked_source.rs index dbfe57197d..b3c39c22bb 100644 --- a/crates/ui-egui/src/linked_source.rs +++ b/crates/ui-egui/src/linked_source.rs @@ -3,7 +3,7 @@ use std::sync::Arc; -use serde_json::json; +use serde_json::{Value, json}; use crate::SolveApp; @@ -94,7 +94,7 @@ pub(crate) fn extract(app: &mut SolveApp, component: u64) { } /// The status line after a command: `done`, then its warnings and notices. -fn report(app: &mut SolveApp, result: &serde_json::Value, done: &str) { +fn report(app: &mut SolveApp, result: &Value, done: &str) { let notes: Vec<&str> = ["warnings", "notices"].iter().flat_map(|k| result[*k].as_array().into_iter().flatten()).filter_map(|w| w.as_str()).collect(); if notes.is_empty() { @@ -156,45 +156,69 @@ pub(crate) fn record(stamps: &mut Vec<(u64, Stamp)>, component: u64, stamp: Stam } } +/// The shown design's linked components whose files are no longer what the design last read +/// (`component.links` `changed`), as when one changed while the design was closed (#9739). +fn changed_since_read(session: &mut solidcraft_engine::Session) -> Vec { + let Ok(links) = session.execute("component.links", &json!({})) else { return Vec::new() }; + links["links"].as_array().into_iter().flatten().filter(|l| l["changed"].as_bool() == Some(true)).filter_map(|l| l["component"].as_u64()).collect() +} + /// The links of the shown design it holds no record of yet (it was just opened, a link was just -/// made) are taken as their files are now. +/// made) are taken as their files are now, except a file that changed since the design last read +/// it (#9739): that one counts as not read yet, so [`update_changed`] reads it. pub(crate) fn note_unread(app: &mut SolveApp) { if app.session.doc.links.keys().all(|c| app.docs.link_stamps.iter().any(|(k, _)| k == c)) { return; } - for (c, s) in disk_stamps(app) { + let now = disk_stamps(app); + let stale = if now.is_empty() { Vec::new() } else { changed_since_read(&mut app.session) }; + for (c, s) in now { if !app.docs.link_stamps.iter().any(|(k, _)| *k == c) { - app.docs.link_stamps.push((c, s)); + app.docs.link_stamps.push((c, if stale.contains(&c) { None } else { s })); } } } -/// The shown design came back to its tab: read again, off the window thread as Update Links -/// does, the linked designs whose files changed since it last read them. Each is a run of its -/// own, so one that cannot be read holds up no other; one that fails is tried again next time. +/// The shown design came back to its tab, or was just opened: read again, off the window thread +/// as Update Links does, the linked designs whose files changed since it last read them. All in +/// one run, so they come in as one undo step (#9743); one that cannot be read holds up no other, +/// keeps its bodies and is tried again next time. pub(crate) fn update_changed(app: &mut SolveApp) { let now = disk_stamps(app); - let changed: Vec = - now.iter().filter(|(c, s)| s.is_some() && app.docs.link_stamps.iter().any(|(k, was)| k == c && was != s)).map(|(c, _)| *c).collect(); + // Links met here for the first time: those whose files changed since the design last read + // them (#9739) are read again too. + let first: Vec = now.iter().map(|(c, _)| *c).filter(|c| !app.docs.link_stamps.iter().any(|(k, _)| k == c)).collect(); + let stale = if first.is_empty() { Vec::new() } else { changed_since_read(&mut app.session) }; + let changed: Vec = now + .iter() + .filter(|(c, s)| s.is_some() && (app.docs.link_stamps.iter().any(|(k, was)| k == c && was != s) || (first.contains(c) && stale.contains(c)))) + .map(|(c, _)| *c) + .collect(); for (c, s) in now { if !app.docs.link_stamps.iter().any(|(k, _)| *k == c) { app.docs.link_stamps.push((c, s)); } } - // More than can wait at once: the rest are still changed when the tab next comes back. - let room = app.file_load.room(); - for component in changed.into_iter().take(room) { - let mut request = crate::file_load::Request::new("", crate::file_load::Kind::UpdateLinks); - request.options.insert("component".into(), json!(component)); - request.design.clone_from(&app.session.path); - let _ = crate::file_load::begin_request(app, request); + // No room to wait: they are still changed when the tab next comes back. + if changed.is_empty() || app.file_load.room() == 0 { + return; } + let mut request = crate::file_load::Request::new("", crate::file_load::Kind::UpdateLinks); + request.options.insert("components".into(), json!(changed)); + request.design.clone_from(&app.session.path); + let _ = crate::file_load::begin_request(app, request); } -/// Update Links read the shown design's link to `component` (`None`: all of them) without an -/// error: those links are now as their files are. -pub(crate) fn read_again(app: &mut SolveApp, component: Option) { - for (c, s) in disk_stamps(app).into_iter().filter(|(c, _)| component.is_none_or(|o| o == *c)) { +/// Update Links (`options` as it was asked) read the shown design's links again: those it read +/// without an error (`result`'s `failed` names the others) are now as their files are. +pub(crate) fn read_again(app: &mut SolveApp, options: &serde_json::Map, result: &Value) { + let asked: Option> = match (options.get("component"), options.get("components")) { + (Some(c), _) => Some(c.as_u64().into_iter().collect()), + (None, Some(list)) => Some(list.as_array().into_iter().flatten().filter_map(Value::as_u64).collect()), + (None, None) => None, + }; + let failed: Vec = result["failed"].as_array().into_iter().flatten().filter_map(|f| f["component"].as_u64()).collect(); + for (c, s) in disk_stamps(app).into_iter().filter(|(c, _)| asked.as_ref().is_none_or(|a| a.contains(c)) && !failed.contains(c)) { record(&mut app.docs.link_stamps, c, s); } } @@ -544,6 +568,24 @@ mod tests { let _ = std::fs::remove_dir_all(folder); } + /// #9743: the links that changed while the tab was away come back as one undo step. + #[test] + fn links_to_a_changed_design_come_back_together_and_one_undo_takes_them_back() { + let (mut app, folder, plate, cube) = two_links("one-step"); + app.run("component.insert_design", json!({"path": plate, "translate": [0, 100, 0]})).unwrap(); + app.run("file.save", json!({})).unwrap(); + assert!((volume(&app) - 13000.0).abs() < 1e-6, "{}", volume(&app)); + crate::documents::switch(&mut app, 1); + save_part(&plate, [10.0, 20.0, 30.0], true); + save_part(&cube, [10.0, 10.0, 10.0], true); + crate::documents::switch(&mut app, 0); + settle(&mut app); + assert!((volume(&app) - 13750.0).abs() < 1e-6, "{}", volume(&app)); + assert_eq!(app.run("edit.undo", json!({})).unwrap()["undone"], "Update Links"); + assert!((volume(&app) - 13000.0).abs() < 1e-6, "every copy of the part goes back together: {}", volume(&app)); + let _ = std::fs::remove_dir_all(folder); + } + #[test] fn a_change_made_while_the_assembly_is_shown_is_read_when_it_next_comes_back() { let (mut app, folder, plate, _) = two_links("shown"); @@ -601,6 +643,36 @@ mod tests { let _ = std::fs::remove_dir_all(folder); } + /// #9739: a linked design changed while the assembly was closed is read when the assembly + /// opens, in place of the blank design or in a tab of its own. Nothing changed, nothing is. + #[test] + fn a_design_changed_while_the_assembly_was_closed_is_read_when_it_opens() { + let (app, folder, plate, _) = two_links("closed"); + drop(app); + let case = folder.join("case.solidcraft"); + let open = |in_tab: bool| { + let mut app = SolveApp::new(Session::default(), Default::default()); + if in_tab { + app.run("solid.box", json!({"length": 1, "width": 1, "height": 1})).unwrap(); + } + let mut opened = Session::default(); + opened.execute("doc.open", &json!({"path": case})).unwrap(); + crate::documents::adopt(&mut app, opened); + assert_eq!(app.docs.count(), if in_tab { 2 } else { 1 }); + app + }; + let app = open(false); + assert!(app.file_load.idle(), "nothing changed: nothing to read"); + assert!((volume(&app) - 7000.0).abs() < 1e-6, "{}", volume(&app)); + save_part(&plate, [10.0, 20.0, 30.0], true); + for in_tab in [false, true] { + let mut app = open(in_tab); + settle(&mut app); + assert!((volume(&app) - 7250.0).abs() < 1e-6, "in a tab of its own: {in_tab}: {}", volume(&app)); + } + let _ = std::fs::remove_dir_all(folder); + } + #[test] fn cancelling_a_close_all_reads_what_the_tab_left_in_front_missed() { let (assembly, folder, plate, _) = two_links("cancelled"); diff --git a/crates/ui-egui/src/measurement_ui_tests.rs b/crates/ui-egui/src/measurement_ui_tests.rs index d2a958c765..c893c81574 100644 --- a/crates/ui-egui/src/measurement_ui_tests.rs +++ b/crates/ui-egui/src/measurement_ui_tests.rs @@ -11,6 +11,88 @@ fn result(h: &Harness) -> &Value { } } +/// Opening the tool must populate its readout before another paint, including when started +/// through the same control request as a toolbar or shortcut command. +#[test] +fn measure_starts_with_selected_vertices_edges_and_faces() { + for kind in ["vertex", "edge", "face"] { + for count in 1..=2 { + let mut h = Harness::new(); + h.app.home.open = false; + h.app.run("solid.box", json!({"length": 30, "width": 40, "height": 12, "body_name": "Box"})).unwrap(); + let points = match kind { + "edge" => [[15., 0., 0.], [15., 40., 0.]], + "face" => [[15., 20., 0.], [15., 20., 12.]], + _ => [[0., 0., 0.], [30., 40., 12.]], + }; + let items: Vec = points + .into_iter() + .take(count) + .map(|point| match kind { + "vertex" => json!({"type":kind,"body":"Box","point":point}), + _ => { + let st = h.app.session.world_state(); + let mesh = st.body("Box").unwrap().mesh(); + let point = solidcraft_engine::geom::Vec3::from(point); + let index = if kind == "edge" { + mesh.edges.iter().position(|e| e.windows(2).any(|w| point.dist_to_segment(w[0], w[1]) < 1e-6)).unwrap() + } else { + mesh.triangles + .iter() + .zip(&mesh.tri_face) + .find_map(|(tri, face)| { + let t = mesh.tri(tri)?; + t.iter().all(|p| (p.z - point.z).abs() < 1e-6).then_some(*face as usize) + }) + .unwrap() + }; + json!({"type":kind,"body":"Box","index":index,"point":point}) + } + }) + .collect(); + h.app.run("select.set", json!({"items":items})).unwrap(); + let expected = h.app.session.measure_items(&h.app.session.selection); + assert!(expected.get("error").is_none(), "{expected}"); + let (req, _rx) = crate::control::ControlRequest::new("ui.start", json!({"command":"inspect.measure"})); + crate::control::handle(&mut h.app, &h.ctx, &req); + let d = h.app.dialog.as_ref().unwrap(); + assert_eq!(d.inputs[0].items.len(), count, "{kind}"); + assert!(h.app.session.selection.is_empty(), "selection belongs to the dialog"); + assert_eq!(*result(&h), expected, "{kind}: result is ready before painting"); + h.call("ui.view", json!({"view":"fit"})); + if count == 2 { + assert!(crate::scenario::handle_at("measure:label").is_some(), "{kind}: distance is visible without repicking"); + } + if kind == "vertex" + && count == 2 + && let Ok(dir) = std::env::var("SOLIDCRAFT_MEASURE_EVIDENCE") + { + h.ctx = egui::Context::default(); + crate::fonts::setup_bundled_fonts(&h.ctx); + let mut textures = std::collections::HashMap::new(); + for frame in 0..6 { + let mut output = h.ctx.run_ui( + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600., 1000.))), + time: Some(frame as f64 / 60.), + ..Default::default() + }, + |ui| h.app.ui(ui), + ); + crate::ui_kit::test_textures_update(&mut textures, &output.textures_delta); + output.textures_delta.clear(); + if frame == 5 { + let bounds = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600., 1000.)); + let image = crate::ui_kit::test_raster(&h.ctx, output.shapes, output.pixels_per_point, bounds, &textures); + std::fs::create_dir_all(&dir).unwrap(); + image.save(std::path::Path::new(&dir).join("measure-preselection.png")).unwrap(); + } + } + } + } + } +} + fn vertex_measurement(a: [f64; 3], b: [f64; 3]) -> Harness { let mut h = Harness::new(); h.app.home.open = false; @@ -142,7 +224,7 @@ fn measurement_axis_distances_can_be_disabled_saved_and_restored_in_preferences( assert!(serde_json::from_value::(json!({})).unwrap().measure_axis_distances, "existing settings get the default-on option"); let mut h = vertex_measurement([0., 0., 0.], [-3., 4., -12.]); let measurement = result(&h).clone(); - h.call("ui.prefs", json!({"open":"Graphics"})); + h.call("ui.prefs", json!({"open":"Tools"})); // The kit's 24 pt rows scroll inside the bounded Preferences page. let page = crate::scenario::rect_of("prefs:page_body").unwrap(); for _ in 0..8 { @@ -166,11 +248,11 @@ fn measurement_axis_distances_can_be_disabled_saved_and_restored_in_preferences( let mut restored = crate::SolveApp::new(Default::default(), Default::default()); restored.load_prefs(&h.app.prefs()); assert!(!restored.preferences.measure_axis_distances, "disabled preference survives restart"); - h.call("ui.prefs", json!({"open":"Graphics","measure_axis_distances":true})); + h.call("ui.prefs", json!({"open":"Tools","measure_axis_distances":true})); assert!(crate::scenario::handle_at("measure:label:x").is_some()); h.call("ui.prefs", json!({"button":"cancel"})); assert!(!h.app.preferences.measure_axis_distances, "Cancel restores the saved choice"); - h.call("ui.prefs", json!({"open":"Graphics"})); + h.call("ui.prefs", json!({"open":"Tools"})); h.call("ui.prefs", json!({"button":"defaults"})); assert!(h.app.preferences.measure_axis_distances); h.call("ui.prefs", json!({"button":"apply"})); @@ -210,6 +292,65 @@ fn the_view_numbers_the_two_picks() { } } +/// The labels in the view can be dragged away from the part (#8510): each follows the pointer, +/// keeps a leader to what it measures, picks nothing and starts no box selection on the way, and +/// goes back to its own place on a double-click or with the next measurement. +#[test] +fn the_measure_labels_drag_away_from_the_part_and_back() { + let mut h = vertex_measurement([0., 0., 0.], [-3., 4., -12.]); + h.call("ui.view", json!({"view": "iso"})); + painted_text(&mut h); + let own = component_rects(); + let measurement = result(&h).clone(); + let picks = h.call("ui.selection", json!({}))["result"].clone(); + let drag = |h: &mut Harness, from: egui::Pos2, by: egui::Vec2| { + h.call("ui.drag", json!({"x0": from.x, "y0": from.y, "x1": from.x + by.x, "y1": from.y + by.y, "steps": 6})); + painted_text(h); + }; + // The distance, then the Z part, each dragged by the grabbed point away from the part. + for (i, by) in [(0, egui::vec2(180.0, -130.0)), (3, egui::vec2(-160.0, 90.0))] { + let from = component_rects()[i].left_top() + egui::vec2(6.0, 4.0); + let before = component_rects(); + drag(&mut h, from, by); + let after = component_rects(); + let moved = after[i].min - before[i].min; + assert!((moved - by).length() < 1.0, "label {i} follows the pointer: moved {moved:?}, dragged {by:?}"); + if i == 3 { + assert_eq!(after[..3], before[..3], "only the dragged label moves"); + } + assert_eq!(*result(&h), measurement, "dragging a label measures nothing new"); + assert_eq!(h.call("ui.selection", json!({}))["result"], picks, "dragging a label picks nothing"); + assert!(h.app.viewport.boxsel.is_none(), "dragging a label starts no box selection"); + } + let dragged = component_rects(); + assert!(!dragged[0].intersects(own[0]), "the distance left its place over the part: {:?} vs {:?}", dragged[0], own[0]); + // The view turns: a dragged label stays away from the line by the same amount. + h.call("ui.view", json!({"view": "front"})); + painted_text(&mut h); + let turned = component_rects(); + h.call("ui.view", json!({"view": "iso"})); + painted_text(&mut h); + assert_eq!(component_rects()[0], dragged[0], "back in the same view, back in the same place"); + assert!(turned.iter().all(|r| r.is_finite())); + // A double-click puts the distance back. + let at = dragged[0].center(); + h.call("ui.click", json!({"x": at.x, "y": at.y, "double": true})); + painted_text(&mut h); + assert_eq!(component_rects()[0], own[0], "a double-click puts the label back"); + // Another measurement puts the Z part back too. + let offsets = |h: &Harness| match &h.app.dialog.as_ref().unwrap().kind { + Kind::Measure { labels, .. } => (0..4).map(|i| labels.offset(i)).collect::>(), + _ => panic!("expected the Measure dialog"), + }; + assert_ne!(offsets(&h)[3], egui::Vec2::ZERO); + if let Some(input) = h.app.dialog.as_mut().and_then(|d| d.inputs.first_mut()) { + input.items = [[0., 0., 0.], [-3., 4., 0.]].into_iter().map(|p| Sel::Vertex { body: "Box".into(), point: p.into() }).collect(); + } + painted_text(&mut h); + assert!((result(&h)["distance_mm"].as_f64().unwrap() - 5.).abs() < 1e-8, "measured the new picks"); + assert!(offsets(&h).iter().all(|o| *o == egui::Vec2::ZERO), "{:?}", offsets(&h)); +} + #[test] fn the_distance_row_has_a_copy_button_that_copies_the_shown_value() { let mut h = vertex_measurement([0., 0., 0.], [3., 4., 0.]); diff --git a/crates/ui-egui/src/menu_tests.rs b/crates/ui-egui/src/menu_tests.rs index f52ff5e95a..50c819cf46 100644 --- a/crates/ui-egui/src/menu_tests.rs +++ b/crates/ui-egui/src/menu_tests.rs @@ -673,6 +673,26 @@ fn delete_key_handles_every_kind_and_asks_about_dependents() { assert_eq!(app.session.doc.features.len(), n); } +/// #9079: Modify ▸ Delete with nothing selected says what to pick, not the command's parameter +/// error, and changes nothing; with something selected it deletes it, as the Delete key does. +#[test] +fn modify_delete_with_nothing_selected_asks_for_a_pick() { + let mut app = SolveApp::new(Session::default(), Services::default()); + app.run("solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Box"})).unwrap(); + app.run("construct.plane.offset", json!({"base": "XY", "offset": 10})).unwrap(); + let n = app.session.doc.features.len(); + app.run("select.clear", json!({})).unwrap(); + app.start("timeline.delete"); + let (message, _, error) = app.status.clone().expect("a hint"); + assert!(!error && message == app.tr("ui.delete_select_first"), "{message:?} error: {error}"); + assert!(!message.contains('`'), "no parameter names: {message}"); + assert_eq!(app.session.doc.features.len(), n, "nothing deleted"); + app.run("select.set", json!({"items": [{"type": "plane", "name": "Plane1"}]})).unwrap(); + app.start("timeline.delete"); + assert!(app.session.doc.find_feature("Plane1").is_none(), "the selection goes"); + assert_eq!(app.session.doc.features.len(), n - 1); +} + #[test] fn browser_feature_menu_deletes_its_target_and_supports_undo() { let mut app = SolveApp::new(Session::default(), Services::default()); diff --git a/crates/ui-egui/src/navigation.rs b/crates/ui-egui/src/navigation.rs index 7c9cf730e8..e0105ed92c 100644 --- a/crates/ui-egui/src/navigation.rs +++ b/crates/ui-egui/src/navigation.rs @@ -366,6 +366,7 @@ fn zoom_point(app: &mut SolveApp, rect: Rect, point: Option, surface: bool } /// Modifier-only navigation never borrows pointer movement from another widget or gesture. +/// Tool fields keep keyboard focus without blocking motion over the exposed viewport. pub(crate) fn modifier_motion( app: &mut SolveApp, ui: &egui::Ui, @@ -376,7 +377,6 @@ pub(crate) fn modifier_motion( ) -> Option<(NavMode, egui::Vec2)> { let owns = inside && ui.input(|i| i.focused && !i.pointer.any_down() && !i.pointer.any_released() && !i.pointer.any_pressed()) - && !ui.ctx().egui_wants_keyboard_input() && !egui::Popup::is_any_open(ui.ctx()) && !crate::dim_view::editing() && app.viewport.context_menu.is_none() @@ -574,6 +574,19 @@ pub(crate) fn handle(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect, resp: &e && resp.contains_pointer() && ui.rect_contains_pointer(rect) && hover.is_some_and(|p| !axes.contains(p) && !readout.is_some_and(|r| r.contains(p))); + // The wheel and trackpad gestures go on under a box anchored to the model (a value box such + // as Extrude's distance, a dimension's editor): it scrolls nothing, and a pan, orbit or zoom + // stopped as it crossed one (#10061). Its clicks stay its own. Not while the wheel steps the + // value under the pointer (Preferences › Design, value stepping). + let wheel_inside = inside + || (ui.is_enabled() + && !crate::value_step::enabled(ui.ctx()) + && hover.is_some_and(|p| { + rect.contains(p) + && !axes.contains(p) + && !readout.is_some_and(|r| r.contains(p)) + && ui.ctx().layer_id_at(p).is_some_and(crate::ui_kit::container::is_canvas) + })); app.viewport.mouse = hover.filter(|_| inside); // Sketch snap suppression is set once in `viewport::show` from preferences. middle_double_click(app, ui, inside); @@ -621,7 +634,7 @@ pub(crate) fn handle(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect, resp: &e // Pinch-to-zoom (trackpad): direct manipulation, never smoothed. let pinch = ui.input(|i| pinch_factor(i, options, page_height, zoom_modifier)); let h = rect.height().max(1.0) as f64; - if inside && pinch != 1.0 { + if wheel_inside && pinch != 1.0 { app.viewport.orbit_free = None; // Spread zooms in: the camera distance shrinks by the pinch factor. let f = f64::from(pinch).powf(-crate::prefs::zoom_sign(app)); @@ -637,7 +650,7 @@ pub(crate) fn handle(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect, resp: &e app.cam.zoom_at(f, anchor); } } - if inside && scroll.is_finite() && scroll != 0.0 { + if wheel_inside && scroll.is_finite() && scroll != 0.0 { app.viewport.orbit_free = None; let f = (-scroll as f64 * 0.0015 * f64::from(app.preferences.wheel_zoom_speed) * crate::prefs::zoom_sign(app)).exp(); let anchor = zoom_anchor(app, rect, hover, caps.orbit); @@ -653,7 +666,7 @@ pub(crate) fn handle(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect, resp: &e } } // Two-finger scroll pans the model with the fingers. - if inside && pan.is_finite() && pan != egui::Vec2::ZERO { + if wheel_inside && pan.is_finite() && pan != egui::Vec2::ZERO { app.viewport.orbit_free = None; if app.preferences.nav == crate::prefs::TOUCHPAD_NAV { camera_motion(app, rect, json!({"motion":"pan","delta":[pan.x,pan.y]})); @@ -662,7 +675,7 @@ pub(crate) fn handle(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect, resp: &e app.cam.pan(f64::from(pan.x), f64::from(pan.y), h); } } - if inside && caps.orbit && trackpad_orbit && raw.shift_pan != egui::Vec2::ZERO { + if wheel_inside && caps.orbit && trackpad_orbit && raw.shift_pan != egui::Vec2::ZERO { app.cancel_view_animation(); app.viewport.orbit_free = None; let sign = crate::prefs::orbit_sign(app); @@ -830,10 +843,18 @@ pub(crate) fn handle(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect, resp: &e if pressed || key || dragging || motion.is_some() || !caps.orbit { app.viewport.spin = false; } else { - orbit_camera(app, f64::from(dt.min(0.1)) * 30.0, 0.0); + let pivot = spin_pivot(app, rect); + let dx = f64::from(dt.min(0.1)) * 30.0; + match pivot { + Some(p) => orbit_about(app, rect, dx, 0.0, p), + None => orbit_camera(app, dx, 0.0), + } ui.ctx().request_repaint(); } } + if !app.viewport.spin { + app.viewport.spin_pivot = None; + } if !app.cam.is_valid() { app.viewport.orbit_free = None; app.cam = Camera::default(); @@ -924,7 +945,7 @@ pub(crate) fn bridge_camera( if free_orbit { cam.rotate_in_view(Vec3::new(-oy * 0.008, -ox * 0.008, 0.0)); } else { - cam.orbit(ox, oy); + orbit_on_screen(cam, ox, oy); } if let Some((p, coords)) = keep { cam.place(p, coords); @@ -976,7 +997,7 @@ pub(crate) fn device_camera( if free_orbit { cam.rotate_in_view(Vec3::new(-rx, -ry, rz) * (2.0 * seconds)); } else { - cam.orbit(ry * 250.0 * seconds, rx * 250.0 * seconds); + orbit_on_screen(cam, ry * 250.0 * seconds, rx * 250.0 * seconds); cam.roll_by(rz * 2.0 * seconds); } if let Some((p, coords)) = keep { @@ -985,6 +1006,16 @@ pub(crate) fn device_camera( } } +/// A 3D mouse's turn in the upright (turntable) orbit: `dx` and `dy` are screen deltas, as for +/// [`Camera::orbit`], which turns about the unrolled view's axes. The device's Spin rolls the +/// view, so its Tilt and Turn are taken back through that roll first: rolled upside down (#10083) +/// or on its side, the cap still turns the view the way it is pushed on screen, not the way the +/// world's axes lie. Unrolled, this is [`Camera::orbit`] exactly. +fn orbit_on_screen(cam: &mut Camera, dx: f64, dy: f64) { + let (sin, cos) = cam.roll.sin_cos(); + cam.orbit(dx * cos + dy * sin, dy * cos - dx * sin); +} + /// Six principal views, twelve edge diagonals and eight isometric corner directions. pub(crate) fn snap_directions() -> impl Iterator { [-1.0, 0.0, 1.0].into_iter().flat_map(|x| { @@ -1144,6 +1175,25 @@ pub fn orbit(app: &mut SolveApp, rect: egui::Rect, dx: f64, dy: f64, press: Opti app.viewport.orbit_from = Some(OrbitFrom { dx, dy, last: app.cam, ..from }); } +/// What continuous orbit turns about, by Orbit around: the view's centre (none), the world origin, +/// or around the cursor the model point in the middle of the view (else the middle of the bodies +/// in view), chosen once when the spin starts. Turning about the view's centre instead let the +/// model swing out of view once earlier cursor orbits had left that centre off the model (#1044). +fn spin_pivot(app: &mut SolveApp, rect: Rect) -> Option { + match app.preferences.orbit_around { + OrbitAround::ViewCentre => None, + OrbitAround::Origin => Some(Vec3::ZERO), + OrbitAround::Cursor => Some(match app.viewport.spin_pivot { + Some(p) => p, + None => { + let p = pivot_under(app, &projection(app, rect), rect.center()); + app.viewport.spin_pivot = Some(p); + p + } + }), + } +} + /// An orbit drag so far: the camera it began with, the pointer's total motion since, the pivot /// and the camera that produced. #[derive(Clone, Copy, Debug, PartialEq)] @@ -1741,6 +1791,47 @@ mod tests { assert!(cam.pitch < std::f64::consts::FRAC_PI_2 && cam.pitch > 1.5); } + /// The upright 3D mouse orbit follows the view, not the world (#10083): with the view rolled + /// upside down or on its side, Tilt and Turn move the model on screen as they do upright, + /// for the cap and for a device app over the bridge. + #[test] + fn the_3d_mouse_turns_a_rolled_or_upside_down_view_the_way_it_is_pushed() { + use super::*; + use std::f64::consts::{FRAC_PI_2, PI}; + // Where the eye moves on screen (along the view's right and up) for one small turn. + let on_screen = |cam: Camera, turn: &dyn Fn(&mut Camera)| { + let mut after = cam; + turn(&mut after); + let (right, up, _) = cam.basis(); + let moved = after.back() - cam.back(); + let (x, y) = (moved.dot(right), moved.dot(up)); + let length = x.hypot(y); + assert!(length > 1e-6, "{cam:?}: the view turns"); + (x / length, y / length) + }; + for (pitch, roll) in [(1.28, PI), (0.0, PI), (0.0, FRAC_PI_2), (0.0, -FRAC_PI_2), (0.3, 2.5)] { + let upright = Camera { yaw: -0.13, pitch, roll: 0.0, ..Default::default() }; + let rolled = Camera { roll, ..upright }; + for rotation in [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.6, -0.8, 0.0]] { + let cap = |cam: &mut Camera| { + device_camera(cam, Motion { translation: [0.0; 3], rotation }, 1e-4, 600.0, true, false, None, None); + }; + let bridge = |cam: &mut Camera| { + let orbit = [rotation[1] * 0.03, rotation[0] * 0.03]; + bridge_camera(cam, BridgeMotion { orbit, ..Default::default() }, 600.0, true, false, false, None, None); + }; + for turn in [&cap as &dyn Fn(&mut Camera), &bridge] { + let (want, got) = (on_screen(upright, turn), on_screen(rolled, turn)); + let tolerance = if pitch == 0.0 || roll == PI { 1e-3 } else { 0.1 }; + assert!( + (want.0 - got.0).hypot(want.1 - got.1) < tolerance, + "pitch {pitch}, roll {roll}, {rotation:?}: {want:?} upright, {got:?} rolled" + ); + } + } + } + } + #[test] fn solidworks_free_orbit_keeps_the_configured_pivot_on_screen() { use super::*; @@ -1889,6 +1980,76 @@ mod tests { assert!(distances.windows(2).all(|pair| pair[0] == pair[1])); } + /// Continuous orbit turns about what Orbit around names (#1044): around the cursor, the model + /// point in the middle of the view stays still on screen for the whole spin, even when the + /// view's centre has been left far behind the model; around the view's centre, as before. + #[test] + fn continuous_orbit_turns_about_the_orbit_around_point() { + use crate::prefs::OrbitAround; + for fov in [0.0, 0.6] { + for around in [OrbitAround::Cursor, OrbitAround::Origin, OrbitAround::ViewCentre] { + let mut s = solidcraft_engine::Session::default(); + s.execute("solid.box", &serde_json::json!({"length":40,"width":30,"height":20})).unwrap(); + let mut app = crate::SolveApp::new(s, crate::Services::default()); + app.preferences.orbit_around = around; + app.cam.fov = fov; + let ctx = crate::fonts::test_context(); + let mut time = 0.0; + let mut frame = |app: &mut crate::SolveApp| { + time += 1.0 / 60.0; + let raw = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))), + time: Some(time), + focused: true, + ..Default::default() + }; + let mut out = ctx.run_ui(raw, |ui| { + let rect = ui.max_rect(); + app.viewport.rect = Some(rect); + let resp = ui.interact(rect, ui.id().with("spin"), egui::Sense::click_and_drag()); + super::handle(app, ui, rect, &resp, super::NavCaps::MODEL); + }); + out.textures_delta.clear(); + }; + frame(&mut app); + app.fit_view(); + // Earlier cursor orbits leave the view's centre far behind the model, which still + // shows in the middle of the view. + let back = app.cam.basis().2; + app.cam.target -= back * 300.0; + app.cam.distance += 300.0; + let rect = app.viewport.rect.unwrap(); + let held = match around { + OrbitAround::Cursor => crate::prefs::pivot_under(&app, &super::projection(&app, rect), rect.center()), + OrbitAround::Origin => super::Vec3::ZERO, + OrbitAround::ViewCentre => app.cam.target, + }; + let start = super::projection(&app, rect).to_screen(held).unwrap(); + let (yaw, target) = (app.cam.yaw, app.cam.target); + app.viewport.spin = true; + for i in 0..90 { + frame(&mut app); + assert!(app.viewport.spin, "{around:?}: nothing stopped the spin"); + let now = super::projection(&app, rect).to_screen(held).unwrap(); + assert!(now.distance(start) < 0.5, "fov {fov} {around:?}, frame {i}: the held point moved {start:?} -> {now:?}"); + } + assert!((app.cam.yaw - yaw).abs() > 0.1, "{around:?}: the view turned"); + match around { + OrbitAround::Cursor => { + assert_eq!(app.viewport.spin_pivot, Some(held), "the point is chosen once"); + let middle = crate::prefs::pivot_under(&app, &super::projection(&app, rect), rect.center()); + assert!(middle.dist(held) < 30.0, "fov {fov}: the model is still in the middle: {middle:?} vs {held:?}"); + } + OrbitAround::ViewCentre => assert!((app.cam.target - target).len() < 1e-9, "the view's centre stays as before"), + OrbitAround::Origin => assert!(app.viewport.spin_pivot.is_none()), + } + app.viewport.spin = false; + frame(&mut app); + assert!(app.viewport.spin_pivot.is_none(), "a stopped spin forgets its point"); + } + } + } + /// A flat page (orbit off) pans and zooms with the same bindings and never turns the view. #[test] fn flat_pages_pan_and_zoom_with_the_same_bindings_but_never_orbit() { diff --git a/crates/ui-egui/src/navigation_bar_tests.rs b/crates/ui-egui/src/navigation_bar_tests.rs index 9dbaf8d944..dbccc573a0 100644 --- a/crates/ui-egui/src/navigation_bar_tests.rs +++ b/crates/ui-egui/src/navigation_bar_tests.rs @@ -471,7 +471,7 @@ fn navigation_context_menus_customise_hide_reset_and_remove() { match action { "customise" => { assert!(h.app.prefs_window.open); - assert_eq!(h.app.prefs_window.section, crate::prefs::PAGE_INTERFACE); + assert_eq!(h.app.prefs_window.section, crate::prefs::PAGE_LAYOUT); assert!(crate::scenario::rect_of("prefs:navbar:preview").is_some()); } "hide" => assert!(!h.app.ui.show_navigation_bar), diff --git a/crates/ui-egui/src/navigation_cube.rs b/crates/ui-egui/src/navigation_cube.rs index 604e536815..26ab2e0488 100644 --- a/crates/ui-egui/src/navigation_cube.rs +++ b/crates/ui-egui/src/navigation_cube.rs @@ -130,6 +130,10 @@ fn navigate(app: &mut SolveApp, action: Option<&str>, dir: Option) { if let Some(d) = dir { params["direction"] = json!([d.x, d.y, d.z]); } + // Preferences › Navigation: a view chosen on the cube also frames the whole model (#10091). + if app.preferences.navigator_fit { + params["fit"] = json!(true); + } let _ = app.run("view.navigate", params); } @@ -143,6 +147,21 @@ fn control(app: &mut SolveApp, ui: &mut egui::Ui, bounds: Rect, action: &str, to hover } +/// Preferences › Navigation: "Invert the view cube rotation arrows" (#10254). With it on, an +/// arrow orbits the camera the way the arrow points (camera-centric, as in Fusion and Inventor) +/// instead of rotating the cube toward the side the arrow sits on, so left and right, and up and +/// down, trade places. The tooltip names the action actually performed. The curved roll arrows +/// are not inverted: they name the rotation they perform, not a direction. +fn inverted_arrow(action: &str) -> Option<&'static str> { + match action { + "left" => Some("right"), + "right" => Some("left"), + "up" => Some("down"), + "down" => Some("up"), + _ => None, + } +} + pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { let outer = axis_rect(app, rect); if !outer.is_positive() { @@ -201,6 +220,18 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { ("up", vec2(0.0, -distance), vec2(0.0, 1.0), "Orbit up 90°"), ("down", vec2(0.0, distance), vec2(0.0, -1.0), "Orbit down 90°"), ] { + let (action, tooltip) = match app.preferences.invert_navigator_arrows.then(|| inverted_arrow(action)).flatten() { + Some(swapped) => ( + swapped, + match swapped { + "right" => "Orbit right 90°", + "left" => "Orbit left 90°", + "down" => "Orbit down 90°", + _ => "Orbit up 90°", + }, + ), + None => (action, tooltip), + }; let at = c + offset * scale; let hovered = control(app, ui, Rect::from_center_size(at, vec2(button_size, button_size) * scale), action, tooltip); if active { @@ -512,6 +543,107 @@ mod tests { assert_eq!(h.app.cam.distance, cam.distance); } + /// #10091: with Preferences › Navigation "Fit the model after a navigator view change" on, a + /// cube face, corner or arrow frames the whole model from the new direction, as Home does; + /// off (the default) the view turns in place, keeping its target and zoom. + #[test] + fn cube_views_frame_the_model_when_the_navigation_preference_asks() { + let mut h = crate::scenario::Harness::new(); + h.call("ui.prefs", json!({"navigator_style":"cube", "look_at_anim":false})); + assert!(!h.app.preferences.navigator_fit, "off by default"); + h.app.run("solid.box", json!({"corner":[100,0,0],"length":20,"width":20,"height":20})).unwrap(); + h.settle(); + let undo = h.app.session.undo.len(); + let away = |h: &mut crate::scenario::Harness| { + h.call("ui.view", json!({"view":"iso"})); + h.app.cam.pan(600.0, 400.0, 500.0); + h.app.cam.distance *= 4.0; + h.frames(2); + h.app.cam + }; + let from = away(&mut h); + let p = crate::scenario::handle_at("navigation_cube:front").unwrap(); + h.call("ui.click", json!({"x":p.x,"y":p.y})); + h.frames(2); + assert!(h.app.cam.back().dist(-Vec3::Y) < 1e-6); + assert_eq!(h.app.cam.target, from.target, "off: the target stays"); + assert_eq!(h.app.cam.distance, from.distance, "off: the zoom stays"); + h.call("ui.prefs", json!({"navigator_fit":true})); + for handle in ["navigation_cube:front", "navigation_cube:top_front_right", "navigation_cube:control:right"] { + let from = away(&mut h); + let p = crate::scenario::handle_at(handle).unwrap(); + h.call("ui.click", json!({"x":p.x,"y":p.y})); + h.frames(2); + if handle == "navigation_cube:front" { + assert!(h.app.cam.back().dist(-Vec3::Y) < 1e-6); + } + assert!(h.app.cam.target.dist(Vec3::new(110.0, 10.0, 10.0)) < 1e-6, "{handle}: looks at the middle of the model, {:?}", h.app.cam.target); + assert!(h.app.cam.distance < from.distance / 2.0, "{handle}: {} vs {}", h.app.cam.distance, from.distance); + } + // Home, then a face, a corner or an arrow: the zoom and the centre stay; only the + // direction changes. + let click = |h: &mut crate::scenario::Harness, handle: &str| { + let p = crate::scenario::handle_at(handle).unwrap_or_else(|| panic!("{handle} is not on the cube")); + h.call("ui.click", json!({"x":p.x,"y":p.y})); + h.frames(2); + }; + click(&mut h, "navigation_cube:control:home"); + let home = h.app.cam; + for handle in ["navigation_cube:front", "navigation_cube:top", "navigation_cube:top_front_right", "navigation_cube:control:right"] { + click(&mut h, "navigation_cube:control:home"); + click(&mut h, handle); + assert!((h.app.cam.distance - home.distance).abs() < 1e-6, "{handle}: {} vs Home's {}", h.app.cam.distance, home.distance); + assert!(h.app.cam.target.dist(home.target) < 1e-6, "{handle}: {:?} vs Home's {:?}", h.app.cam.target, home.target); + } + assert_eq!(h.app.session.undo.len(), undo, "a view change edits nothing"); + } + + /// #10254: with Preferences › Navigation "Invert the view cube rotation arrows" on, the + /// arrow on the left orbits the camera the way it points (right), and the other arrows swap + /// the same way; the curved roll arrows keep their action. + #[test] + fn inverted_arrows_orbit_the_way_they_point() { + let mut h = crate::scenario::Harness::new(); + h.call("ui.prefs", json!({"navigator_style":"cube", "look_at_anim":false})); + assert!(!h.app.preferences.invert_navigator_arrows, "off by default"); + let turn = |h: &mut crate::scenario::Harness, action: &str| -> Camera { + h.call("ui.view", json!({"view":"front"})); + h.frames(2); + serde_json::from_value(h.app.session.execute("view.navigate", &json!({"camera": h.app.cam, "action": action})).unwrap()["camera"].clone()) + .unwrap() + }; + let click = |h: &mut crate::scenario::Harness, handle: &str| { + h.call("ui.view", json!({"view":"front"})); + h.frames(2); + let p = crate::scenario::handle_at(&format!("navigation_cube:control:{handle}")).unwrap(); + h.call("ui.click", json!({"x":p.x,"y":p.y})); + h.frames(2); + h.app.cam + }; + let on_the_left = |handle: &str| { + let outer = crate::scenario::rect_of("axis_view:outer").unwrap(); + crate::scenario::handle_at(&format!("navigation_cube:control:{handle}")).unwrap().x < outer.center().x + }; + // Off (the default): the arrow on the left runs the "left" orbit, as before. + assert!(on_the_left("left"), "off: the left action sits on the cube's left side"); + assert_eq!(click(&mut h, "left"), turn(&mut h, "left")); + assert_eq!(click(&mut h, "up"), turn(&mut h, "up")); + h.call("ui.prefs", json!({"invert_navigator_arrows":true})); + h.frames(2); + // On: the arrow on the left now runs the "right" orbit, and the sides trade places. + assert!(on_the_left("right"), "on: the arrow on the left runs the right orbit"); + assert_eq!(click(&mut h, "right"), turn(&mut h, "right")); + assert_eq!(click(&mut h, "left"), turn(&mut h, "left")); + assert_eq!(click(&mut h, "down"), turn(&mut h, "down")); + // The curved roll arrows are not inverted. + h.call("ui.view", json!({"view":"front"})); + h.frames(2); + let camera = h.app.cam; + let p = crate::scenario::handle_at("navigation_cube:control:clockwise").unwrap(); + h.call("ui.click", json!({"x":p.x,"y":p.y})); + assert!((h.app.cam.roll - camera.roll - std::f64::consts::FRAC_PI_2).abs() < 1e-6, "clockwise still rolls clockwise"); + } + #[test] fn cube_navigation_eases_through_intermediate_views_and_can_be_interrupted() { let mut h = crate::scenario::Harness::new(); diff --git a/crates/ui-egui/src/op_auto_tests.rs b/crates/ui-egui/src/op_auto_tests.rs index 6275214bf4..39f058d9c4 100644 --- a/crates/ui-egui/src/op_auto_tests.rs +++ b/crates/ui-egui/src/op_auto_tests.rs @@ -214,6 +214,29 @@ fn hidden_bodies_take_no_part() { assert_eq!(op(&mut h), NEW); } +#[test] +fn into_a_hidden_body_with_nothing_shown_is_a_new_body_until_it_is_shown() { + // The profile on the hidden cube's base, extruded into it (#9919). + let mut h = cube(); + h.app.run("browser.visibility", json!({"items": ["Base"], "visible": false})).unwrap(); + rect_sketch(&mut h, [0.0, 0.0], [10.0, 10.0]); + h.step(&json!({"start": "solid.extrude"})).unwrap(); + checked(&mut h); + assert_eq!(op(&mut h), NEW, "nothing shown to cut"); + let preview = h.app.dialog.as_ref().map(|d| crate::dialogs::apply_commands(&h.app, d)); + assert!(preview.is_some_and(|c| c.is_ok_and(|c| !c.is_empty())), "the extrude runs"); + // Shown while the window is open: the extrude goes into it, a Cut. + h.app.run("browser.visibility", json!({"items": ["Base"], "visible": true})).unwrap(); + checked(&mut h); + assert_eq!(op(&mut h), CUT, "the shown cube is cut"); + // Hidden again: New Body again. + h.app.run("browser.visibility", json!({"items": ["Base"], "visible": false})).unwrap(); + checked(&mut h); + assert_eq!(op(&mut h), NEW); + ok(&mut h); + assert_eq!(bodies(&h).len(), 2, "{:?}", bodies(&h)); +} + #[test] fn an_edited_feature_keeps_its_operation() { let mut h = cube(); diff --git a/crates/ui-egui/src/params_dialog.rs b/crates/ui-egui/src/params_dialog.rs index 2b652ebed4..4cfd352d95 100644 --- a/crates/ui-egui/src/params_dialog.rs +++ b/crates/ui-egui/src/params_dialog.rs @@ -527,8 +527,8 @@ fn table(app: &mut SolveApp, ui: &mut egui::Ui, d: &mut ParamsDialog, rows: &[&R } _ => { let rename = crate::i18n::tr_label("Double-click to rename"); - // Always tip the full name: the Name cell clips long identifiers (#8533). - let response = ui_kit::text(ui, &r.name, ui_kit::Text::NameCell(false)).on_hover_text(format!("{}\n{rename}", r.name)); + // Keep long identifiers readable (#8533) without stacking the cell's own tooltip. + let response = ui_kit::name_cell_with_hint(ui, &r.name, false, rename); crate::scenario::publish_handle(&format!("param:name:{}", r.name), response.rect.center()); if response.double_clicked() { d.renaming = Some((r.name.clone(), r.name.clone())); @@ -953,6 +953,54 @@ pub fn complete(ui: &mut egui::Ui, resp: &egui::Response, text: &mut String) -> mod tests { use super::*; + #[test] + fn parameter_names_show_one_tooltip_and_only_repeat_clipped_names() { + fn text_in(shape: &egui::Shape, within: egui::Rect, texts: &mut Vec) { + match shape { + egui::Shape::Text(text) if within.contains(text.pos) => texts.push(text.galley.text().to_owned()), + egui::Shape::Vec(shapes) => { + for shape in shapes { + text_in(shape, within, texts); + } + } + _ => {} + } + } + + let long = "Driver_MountHole_pitch_diameter_".repeat(2); + for name in ["rip_length", long.as_str()] { + for favorite in [false, true] { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.app.run("parameters.add", json!({"name":name,"expression":"140 mm"})).unwrap(); + h.app.run("parameters.favorite", json!({"name":name,"favorite":favorite})).unwrap(); + h.step(&json!({"start":"parameters.change"})).unwrap(); + h.frames(6); + let at = crate::scenario::handle_at(&format!("param:name:{name}")).unwrap(); + h.step(&json!({"move":[at.x,at.y]})).unwrap(); + h.frames(90); + let tooltips: Vec<_> = h.ctx.memory(|m| { + m.areas() + .visible_layer_ids() + .into_iter() + .filter(|layer| layer.order == egui::Order::Tooltip) + .filter_map(|layer| m.area_rect(layer.id)) + .collect() + }); + assert_eq!(tooltips.len(), 1, "one name must not stack tooltips: {name}, favorite={favorite}"); + let mut texts = Vec::new(); + for shape in &h.shapes { + text_in(&shape.shape, tooltips[0], &mut texts); + } + let hint = crate::i18n::tr_label("Double-click to rename"); + let expected = if name == "rip_length" { hint.to_owned() } else { format!("{name}\n{hint}") }; + assert_eq!(texts, [expected], "only a clipped name needs repeating"); + h.step(&json!({"click":{"handle":format!("param:name:{name}")},"double":true})).unwrap(); + assert!(h.app.params.as_ref().unwrap().renaming.is_some(), "the name still renames by double-click"); + } + } + } + fn escape_dialog() -> crate::scenario::Harness { let mut h = crate::scenario::Harness::new(); h.app.home.open = false; diff --git a/crates/ui-egui/src/pick_index.rs b/crates/ui-egui/src/pick_index.rs index 8ff6956b2b..859f97de68 100644 --- a/crates/ui-egui/src/pick_index.rs +++ b/crates/ui-egui/src/pick_index.rs @@ -17,7 +17,30 @@ use crate::viewport::Proj; /// Indexes by mesh address, each with its mesh held (so the address stays its own); `None` /// while a worker is still making it. #[derive(Default)] -pub(crate) struct PickIndexes(Arc>); +pub(crate) struct PickIndexes(Arc>, #[cfg(test)] Arc); + +/// Lets a test hold the worker before it starts making an index, so "still being made" can be +/// observed without racing the clock. Open unless a test closes it. +#[cfg(test)] +#[derive(Default)] +pub(crate) struct TestGate { + closed: Mutex, + changed: std::sync::Condvar, +} + +#[cfg(test)] +impl TestGate { + fn wait_open(&self) { + let mut closed = self.closed.lock().unwrap(); + while *closed { + closed = self.changed.wait(closed).unwrap(); + } + } + pub(crate) fn set_closed(&self, value: bool) { + *self.closed.lock().unwrap() = value; + self.changed.notify_all(); + } +} type Indexes = HashMap, Option>)>; @@ -39,7 +62,11 @@ impl PickIndexes { if size > AT_ONCE { map.insert(key, (mesh.clone(), None)); let (indexes, source) = (self.0.clone(), mesh.clone()); + #[cfg(test)] + let gate = self.1.clone(); let started = std::thread::Builder::new().name("pick-index".into()).spawn(move || { + #[cfg(test)] + gate.wait_open(); let index = Arc::new(MeshIndex::new(&source)); if let Ok(mut map) = indexes.lock() && let Some(slot) = map.get_mut(&key) @@ -64,6 +91,17 @@ impl PickIndexes { } } + #[cfg(test)] + pub(crate) fn gate(&self) -> Arc { + self.1.clone() + } + + /// Indexes still being made by a worker. + #[cfg(test)] + pub(crate) fn pending(&self) -> usize { + self.0.lock().map_or(0, |m| m.values().filter(|(_, i)| i.is_none()).count()) + } + #[cfg(test)] pub(crate) fn len(&self) -> usize { self.0.lock().map_or(0, |m| m.len()) diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index 3eaa2e09df..a4dcb0a950 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -1,6 +1,8 @@ //! Grouped preferences composed with the shared UI kit. Changes apply immediately; Cancel //! restores the opening snapshot, Apply keeps editing, and Defaults affects the current page. +use std::collections::BTreeMap; + use egui::{Modifiers, Pos2}; use serde::{Deserialize, Serialize}; use serde_json::json; @@ -26,6 +28,7 @@ mod interface; mod navbar; mod navigation; mod sketch; +mod tools; use crate::SolveApp; use crate::theme::Tokens; @@ -129,6 +132,18 @@ pub const NAV_PRESETS: &[NavPreset] = &[ bind(Right, NavKeys::NONE, Orbit), ], }, + NavPreset { + id: "nx", + label: "NX", + // NX's CAD1 scheme drags middle to rotate, middle + left to zoom and middle + right to + // pan; the bindings here hold one button at a time, so the two-button drags are the + // middle drag with Ctrl (zoom) and Shift (pan). + help: "Middle drag orbits, Shift+middle pans (NX's middle + right), Ctrl+middle zooms (NX's middle + left); right drag leaves the camera", + reverse_zoom: false, + middle_double_click: Some("fit"), + right_drag_orbits: Some(false), + bindings: &[bind(Middle, NavKeys::SHIFT, Pan), bind(Middle, NavKeys::CTRL, Zoom), bind(Middle, NavKeys::NONE, Orbit)], + }, NavPreset { id: "inventor", label: "Inventor", @@ -271,68 +286,110 @@ pub fn choose_nav_preset(prefs: &mut Prefs, preset: &NavPreset) { } } /// Stable control-channel names; presentation labels may be shorter. -pub const PAGES: &[&str] = &["General", "Interface", "Design", "Navigation", "Keyboard Shortcuts", "Sketch", "Graphics", "AI", "Plugins"]; +pub const PAGES: &[&str] = + &["General", "Appearance", "Layout", "Graphics", "Design", "Sketch", "Tools", "Navigation", "3D mouse", "Shortcuts", "Assistant", "Plugins"]; pub const PAGE_GENERAL: usize = 0; -pub const PAGE_INTERFACE: usize = 1; -pub const PAGE_DESIGN: usize = 2; -pub const PAGE_NAVIGATION: usize = 3; -pub const PAGE_SHORTCUTS: usize = 4; +pub const PAGE_APPEARANCE: usize = 1; +pub const PAGE_LAYOUT: usize = 2; +pub const PAGE_GRAPHICS: usize = 3; +pub const PAGE_DESIGN: usize = 4; pub const PAGE_SKETCH: usize = 5; -pub const PAGE_GRAPHICS: usize = 6; -pub const PAGE_AI: usize = 7; -pub const PAGE_PLUGINS: usize = 8; +pub const PAGE_TOOLS: usize = 6; +pub const PAGE_NAVIGATION: usize = 7; +pub const PAGE_MOUSE3D: usize = 8; +pub const PAGE_SHORTCUTS: usize = 9; +pub const PAGE_ASSISTANT: usize = 10; +pub const PAGE_PLUGINS: usize = 11; /// What Preferences accepts for `parameter_list_rows`: `parameters.complete` answers at most 200. pub const PARAMETER_LIST_ROWS: std::ops::RangeInclusive = 5..=200; const WINDOW_PAGES: &[ui_kit::WindowPage] = &[ - ui_kit::WindowPage { icon: "settings", label: "ui.general", group: None, handle: "prefs:page:General", scroll: true, draw: window_page::<0> }, + ui_kit::WindowPage { + icon: "settings", + label: "ui.general", + group: None, + handle: "prefs:page:General", + scroll: true, + draw: window_page::, + }, ui_kit::WindowPage { icon: "settings_palette", - label: "prefs.interface", - group: None, - handle: "prefs:page:Interface", + label: "prefs.structure.appearance", + group: Some("prefs.structure.look"), + handle: "prefs:page:Appearance", scroll: true, - draw: window_page::<1>, + draw: window_page::, + }, + ui_kit::WindowPage { + icon: "settings_monitor", + label: "prefs.structure.layout", + group: None, + handle: "prefs:page:Layout", + scroll: true, + draw: window_page::, + }, + ui_kit::WindowPage { + icon: "settings_monitor", + label: "ui.graphics", + group: None, + handle: "prefs:page:Graphics", + scroll: true, + draw: window_page::, + }, + ui_kit::WindowPage { + icon: "ws_design", + label: "ui.design", + group: Some("prefs.structure.work"), + handle: "prefs:page:Design", + scroll: true, + draw: window_page::, + }, + ui_kit::WindowPage { + icon: "sketch", + label: "prefs.structure.sketch", + group: None, + handle: "prefs:page:Sketch", + scroll: true, + draw: window_page::, + }, + ui_kit::WindowPage { + icon: "settings", + label: "prefs.structure.tools", + group: None, + handle: "prefs:page:Tools", + scroll: true, + draw: window_page::, }, - ui_kit::WindowPage { icon: "ws_design", label: "ui.design", group: None, handle: "prefs:page:Design", scroll: true, draw: window_page::<2> }, ui_kit::WindowPage { icon: "settings_mouse", label: "ui.navigation", group: Some("prefs.input"), handle: "prefs:page:Navigation", scroll: true, - draw: window_page::<3>, + draw: window_page::, + }, + ui_kit::WindowPage { + icon: "settings_mouse", + label: "prefs.structure.mouse3d", + group: None, + handle: "prefs:page:3D mouse", + scroll: true, + draw: window_page::, }, ui_kit::WindowPage { icon: "settings_keyboard", label: "prefs.shortcuts", group: None, - handle: "prefs:page:Keyboard Shortcuts", + handle: "prefs:page:Shortcuts", scroll: false, - draw: window_page::<4>, - }, - ui_kit::WindowPage { - icon: "sketch", - label: "prefs.sketch_selection", - group: None, - handle: "prefs:page:Sketch", - scroll: true, - draw: window_page::<5>, - }, - ui_kit::WindowPage { - icon: "settings_monitor", - label: "ui.graphics", - group: Some("prefs.display"), - handle: "prefs:page:Graphics", - scroll: true, - draw: window_page::<6>, + draw: window_page::, }, ui_kit::WindowPage { icon: "assistant", label: "prefs.assistant", group: Some("prefs.extend"), - handle: "prefs:page:AI", + handle: "prefs:page:Assistant", scroll: true, - draw: window_page::<7>, + draw: window_page::, }, ui_kit::WindowPage { icon: "settings_plug", @@ -340,10 +397,30 @@ const WINDOW_PAGES: &[ui_kit::WindowPage] = &[ group: None, handle: "prefs:page:Plugins", scroll: true, - draw: window_page::<8>, + draw: window_page::, }, ]; +/// Former page names remain usable through both ui.prefs and scenario handles. +const PAGE_ALIASES: &[(&str, usize)] = &[ + ("AI", PAGE_ASSISTANT), + ("Interface", PAGE_APPEARANCE), + ("Interface/navigation bar", PAGE_LAYOUT), + ("Keyboard Shortcuts", PAGE_SHORTCUTS), + ("Selection", PAGE_SKETCH), + ("Sketch & selection", PAGE_SKETCH), + ("Sketch and selection", PAGE_SKETCH), +]; + +fn page_path(index: usize) -> String { + let Some(page) = WINDOW_PAGES.get(index) else { return String::new() }; + let group = WINDOW_PAGES.get(..=index).and_then(|pages| pages.iter().rev().find_map(|page| page.group)); + match group { + Some(group) => format!("{} > {}", crate::i18n::tr(group), crate::i18n::tr(page.label)), + None => crate::i18n::tr(page.label).to_owned(), + } +} + fn search_header(ui: &mut egui::Ui, app: &mut SolveApp) { let typed = drive_search(ui, app); let (field, _) = @@ -457,8 +534,12 @@ fn clear_search(app: &mut SolveApp) { } fn window_page(ui: &mut egui::Ui, app: &mut SolveApp) { - if let Some(rect) = crate::scenario::rect_of("prefs:page:Sketch") { - crate::scenario::publish_target("prefs:page:Selection", rect); + for (alias, index) in PAGE_ALIASES { + if let Some(page) = WINDOW_PAGES.get(*index) + && let Some(rect) = crate::scenario::rect_of(page.handle) + { + crate::scenario::publish_target(&format!("prefs:page:{alias}"), rect); + } } if let Some(page) = app.prefs_window.land_on.take() { if app.prefs_window.section != page { @@ -899,7 +980,7 @@ preference_settings! { /// Show the radial ring and enable hold/flick gestures for viewport context menus. radial_context_menu: bool = false, PAGE_NAVIGATION => check("Radial context menu"); /// Work on the model directly in the viewport (pull a face to extrude or cut); off by default. - direct_interaction: bool = false, PAGE_NAVIGATION => check("Direct interaction", "Work on the model directly in the viewport. Press a flat face or a profile and pull: up extrudes, down into a body cuts, and sideways movement sets the step size"); + direct_interaction: bool = false, PAGE_TOOLS => check("Direct interaction", "Work on the model directly in the viewport. Press a flat face or a profile and pull: up extrudes, down into a body cuts, and sideways movement sets the step size"); /// Mouse gestures: a ring of tools on the right button. mouse_gestures: crate::mouse_gesture::Config = Default::default(), PAGE_NAVIGATION; /// The wheel zooms out when turned forward. @@ -911,37 +992,37 @@ preference_settings! { /// read, so an existing choice survives the upgrade; this version only writes `false`. orbit_cursor: bool = false, PAGE_NAVIGATION; /// Whether a connected 3D mouse moves the view. - space_mouse_enabled: bool = true, PAGE_NAVIGATION => check("Navigate with the 3D mouse"); + space_mouse_enabled: bool = true, PAGE_MOUSE3D => check("Navigate with the 3D mouse"); /// Whether a picture of the connected 3D mouse, moving as it does, sits in the 3D view's lower /// left corner. Off by default. - space_mouse_in_view: bool = false, PAGE_NAVIGATION => check_id("ui.3d_mouse_show_in_view"); + space_mouse_in_view: bool = false, PAGE_MOUSE3D => check_id("ui.3d_mouse_show_in_view"); /// 3D mouse orbit: traced (on) turns the view around the model in the middle of the view when /// the turn begins (else the middle of the bodies in view), marked by a faint ball; classic /// (off, the default) turns it around the view's centre, as it did before. - space_mouse_model_pivot: bool = false, PAGE_NAVIGATION; + space_mouse_model_pivot: bool = false, PAGE_MOUSE3D; /// Whether the 3D mouse also turns the view while a sketch is edited and still seen square on /// (#2227), as the mouse can. Off (the default): the cap pans and zooms a square-on sketch and /// turns it only after the view is tilted away from it. - space_mouse_sketch_orbit: bool = false, PAGE_NAVIGATION => check_id("ui.3d_mouse_sketch_orbit"); + space_mouse_sketch_orbit: bool = false, PAGE_MOUSE3D => check_id("ui.3d_mouse_sketch_orbit"); /// Speed multiplier for 3D mouse navigation. - space_mouse_speed: f32 = 1.0, PAGE_NAVIGATION; + space_mouse_speed: f32 = 1.0, PAGE_MOUSE3D; /// Per physical axis of the cap (push X, Y, Z, then tilt X, Y, Z), whether it moves the view /// the other way. - space_mouse_reverse: [bool; 6] = [false; 6], PAGE_NAVIGATION; + space_mouse_reverse: [bool; 6] = [false; 6], PAGE_MOUSE3D; /// Per physical axis of the cap, a multiplier on top of `space_mouse_speed`; 0 switches it off. - space_mouse_axis_speed: [f32; 6] = [1.0; 6], PAGE_NAVIGATION; + space_mouse_axis_speed: [f32; 6] = [1.0; 6], PAGE_MOUSE3D; /// Per physical axis of the cap, which of its motions drives it (`space_mouse::reassigned`), /// applied after calibration; each motion drives exactly one axis. - space_mouse_axis_source: [usize; 6] = crate::space_mouse::SAME_AXES, PAGE_NAVIGATION; + space_mouse_axis_source: [usize; 6] = crate::space_mouse::SAME_AXES, PAGE_MOUSE3D; /// Which program reads the 3D mouse when spacenavd and the device itself are both there (Linux). - space_mouse_driver: SpaceMouseDriver = SpaceMouseDriver::default(), PAGE_NAVIGATION; + space_mouse_driver: SpaceMouseDriver = SpaceMouseDriver::default(), PAGE_MOUSE3D; /// Where the 3D mouse picture sits in the 3D view, when it is shown there (`space_mouse_in_view`). - space_mouse_ghost_position: SpaceMouseGhost = SpaceMouseGhost::default(), PAGE_NAVIGATION; + space_mouse_ghost_position: SpaceMouseGhost = SpaceMouseGhost::default(), PAGE_MOUSE3D; /// The user turned the calibration offer off for good ("Never show again"). Unlike closing the /// card, which only records that device, this stops every offer, for every device: it is one flag, /// not kept per device key. Like the settings beside it, the dialog's Restore Defaults puts it back /// (the 3D mouse section's own restore leaves it). - space_mouse_offer_never: bool = false, PAGE_NAVIGATION; + space_mouse_offer_never: bool = false, PAGE_MOUSE3D; /// Per 3D mouse (`space_mouse::Input::device`), the calibration that puts its axes on /// 3Dconnexion's. A device without one is read as it reports. Page resets keep calibrations. #[serde(deserialize_with = "calibrations_that_read")] @@ -960,9 +1041,9 @@ preference_settings! { /// A device's own app on this computer moves the view (the navigation bridge, docs/3d-mouse.md): /// the desktop app connects to it on `nav_bridge_port`. Off unless asked for, so the app /// doesn't connect to whatever else listens on that port. - nav_bridge_enabled: bool = false, PAGE_NAVIGATION; + nav_bridge_enabled: bool = false, PAGE_MOUSE3D; /// The loopback port the device app listens on (0: none). - nav_bridge_port: u16 = 8765, PAGE_NAVIGATION; + nav_bridge_port: u16 = 8765, PAGE_MOUSE3D; /// Anti-aliasing samples (applies on restart). msaa: u32 = 4, PAGE_GRAPHICS; /// Share shading and Continuity; false preserves their independent legacy behavior. @@ -974,6 +1055,9 @@ preference_settings! { graphics_adapter: String = String::new(), PAGE_GRAPHICS; /// Soft contact shading in the GPU viewport; optional and applied immediately. ambient_occlusion: bool = false, PAGE_GRAPHICS; + /// Shade the viewport in linear light with a sky fill, so dark parts keep their shape + /// (#7974). Off by default: the viewport shades as it always has. + dark_parts_shading: bool = false, PAGE_GRAPHICS => check_id("prefs.dark_parts_shading"); /// Optional schematic markings on threaded cylindrical walls; off for new and older preferences. cosmetic_threads: bool = false, PAGE_GRAPHICS; /// The grid turns to the upright origin plane facing a side view (#1470). Off by default: the @@ -1005,7 +1089,7 @@ preference_settings! { /// Off by default; View ▸ View angles and `view.angle_readout` turn it on. show_view_angles: bool = false, PAGE_GRAPHICS; /// Show world X/Y/Z component dimensions beside the total distance in Measure. - measure_axis_distances: bool = true, PAGE_GRAPHICS => check_id("settings.measure_axis_distances"); + measure_axis_distances: bool = true, PAGE_TOOLS => check_id("settings.measure_axis_distances"); /// Perspective view's vertical field of view in radians (#5364), 15° to 90°. The view reads a /// saved value outside that range as the default ([`perspective_fov`]). perspective_fov: f64 = PERSPECTIVE_FOV, PAGE_GRAPHICS, accept(15f64.to_radians()..=90f64.to_radians()); @@ -1047,103 +1131,112 @@ preference_settings! { /// The largest design file opened or saved, in MB (#4086): from the default (256) up to 16 GB. max_design_file_mb: u64 = DEFAULT_DESIGN_MB, PAGE_GENERAL; /// Explicit assembly Move uses joint handles; off keeps the previous Move panel. - assembly_move_handles: bool = true, PAGE_DESIGN => check_id("joint_move.use_handles"); + assembly_move_handles: bool = true, PAGE_TOOLS => check_id("joint_move.use_handles"); /// Degrees per step near the assembly Move ring. - joint_rotation_coarse: f64 = 10.0, PAGE_DESIGN; + joint_rotation_coarse: f64 = 10.0, PAGE_TOOLS; /// Degrees per step after pulling the cursor outside the precision guide. - joint_rotation_fine: f64 = 0.1, PAGE_DESIGN; + joint_rotation_fine: f64 = 0.1, PAGE_TOOLS; /// `dark`, `light` or `system` (follow the operating system), or a theme file's id: a /// built-in one (`gruvbox-dark`, …) or one of `custom_themes` (#2255). - theme: String = "dark".into(), PAGE_INTERFACE; + theme: String = "dark".into(), PAGE_APPEARANCE; /// The 3D view's colours: the theme's own, or the Light or Dark theme's whatever the window /// uses (#4146). - viewport_theme: crate::theme::ViewportTheme = crate::theme::ViewportTheme::Theme, PAGE_INTERFACE; + viewport_theme: crate::theme::ViewportTheme = crate::theme::ViewportTheme::Theme, PAGE_APPEARANCE; /// The 3D view's background, one colour for the whole application (#7688), or `None` (the /// default) for the theme's gradient. - viewport_background: Option<[u8; 3]> = None, PAGE_INTERFACE; + viewport_background: Option<[u8; 3]> = None, PAGE_APPEARANCE; /// The application bar's logo in the theme's accent instead of the theme's logo colours. - logo_accent: bool = false, PAGE_INTERFACE; + logo_accent: bool = false, PAGE_APPEARANCE; /// Imported theme files (`theme_files`), kept here so they also work in the browser. + #[serde(deserialize_with = "themes_that_read")] custom_themes: Vec = Vec::new(); /// Desktop: a `solidcraft-theme/1` file another tool writes, followed as the theme and /// reloaded when it changes (#7573). Empty (the default): no file is followed. - theme_follow: String = String::new(), PAGE_INTERFACE; - /// Interface language (a registered locale ID). - language: String = "en".into(), 0; + theme_follow: String = String::new(), PAGE_APPEARANCE; + /// Per-theme overrides. Raw JSON retains entries from newer or hand-edited settings. + theme_options: BTreeMap> = BTreeMap::new(), PAGE_APPEARANCE; + /// Interface language: a registered locale ID, or `system` to follow the system's language + /// on every start (#10138). A saved language keeps winning, so existing users don't change. + language: String = "system".into(), 0; /// Shared workspace composition; system windows and popups retain their purpose. - layout: crate::shell::LayoutMode = Default::default(), PAGE_INTERFACE; + layout: crate::shell::LayoutMode = Default::default(), PAGE_LAYOUT; /// Ordered view controls and their appearance. #[serde(default)] - navigation_bar: crate::navigation_bar::NavigationBar = Default::default(), PAGE_INTERFACE; + navigation_bar: crate::navigation_bar::NavigationBar = Default::default(), PAGE_LAYOUT; /// Show command names beneath ribbon icons, independently of workspace layout. - ribbon_labels: bool = true, PAGE_INTERFACE; + ribbon_labels: bool = true, PAGE_LAYOUT; /// Show engine-derived persistent selection in the ribbon; off retains neutral buttons. - persistent_ribbon_pressed: bool = true, PAGE_INTERFACE => check_id("prefs.persistent_ribbon_pressed"); - viewport_analysis_status: bool = true, PAGE_INTERFACE => check_id("prefs.viewport_analysis_status"); - viewport_section_status: bool = true, PAGE_INTERFACE => check_id("prefs.viewport_section_status"); - viewport_status_summary: bool = true, PAGE_INTERFACE => check_id("prefs.viewport_status_summary"); + persistent_ribbon_pressed: bool = true, PAGE_LAYOUT => check_id("prefs.persistent_ribbon_pressed"); + viewport_analysis_status: bool = true, PAGE_GRAPHICS => check_id("prefs.viewport_analysis_status"); + viewport_section_status: bool = true, PAGE_GRAPHICS => check_id("prefs.viewport_section_status"); + viewport_status_details: bool = true, PAGE_GRAPHICS => check_id("prefs.viewport_status_details"); + viewport_status_summary: bool = true, PAGE_GRAPHICS => check_id("prefs.viewport_status_summary"); /// Keep the SKETCH tab in the ribbon, right after MODEL, while no sketch is open; its drawing /// tools then start a sketch first (#7904). - sketch_tab_always: bool = false, PAGE_INTERFACE => check_id("prefs.sketch_tab_always"); + sketch_tab_always: bool = false, PAGE_LAYOUT => check_id("prefs.sketch_tab_always"); + /// Each design tab keeps its open dialog or tool, and its toolbar tab, while another design is + /// shown, and has them back when it is shown again (#9360). Off by default: a switch cancels + /// them and shows the MODEL tab. + keep_work_on_tab_switch: bool = false, PAGE_LAYOUT => check_id("prefs.keep_work_on_tab_switch"); /// Hovering a result in Search commands shows the command's place in the ribbon: its tab, /// its button or its row in a section's drop-down (#7287). A command started from the search /// blinks there, and a running command that sits in a drop-down marks its section label. Off /// by default: the ribbon stays as it was. - ribbon_reveal: bool = false, PAGE_INTERFACE => check_id("prefs.ribbon_reveal"); + ribbon_reveal: bool = false, PAGE_LAYOUT => check_id("prefs.ribbon_reveal"); /// Where a dialog's OK and Cancel sit: under its inputs (the default) or in its title row (#6708). - dialog_buttons: crate::ui_kit::DialogButtons = Default::default(), PAGE_INTERFACE; + dialog_buttons: crate::ui_kit::DialogButtons = Default::default(), PAGE_LAYOUT; /// Where notices and offers stack: the view's bottom-right corner (the default) or the bottom /// centre, as before #7893. - toast_place: crate::viewport::ToastPlace = Default::default(), PAGE_INTERFACE; + toast_place: crate::viewport::ToastPlace = Default::default(), PAGE_LAYOUT; /// Toolbar tabs hidden by the person. An empty list keeps every existing tab visible. - hidden_toolbar_tabs: Vec = Vec::new(), PAGE_INTERFACE; + hidden_toolbar_tabs: Vec = Vec::new(), PAGE_LAYOUT; /// Show the ribbon's panel drop-downs as a grid of large icons, their names in the tooltips, /// instead of a list of small icons with names (#4739). - ribbon_icon_grid: bool = false, PAGE_INTERFACE; + ribbon_icon_grid: bool = false, PAGE_LAYOUT; /// Leave the commands the ribbon shows as buttons (pinned ones) out of its panel drop-downs, /// which then list only the rest (#4739). - ribbon_menus_skip_pinned: bool = false, PAGE_INTERFACE => check_tr("ui.ribbon_menus_skip_pinned"); + ribbon_menus_skip_pinned: bool = false, PAGE_LAYOUT => check_tr("ui.ribbon_menus_skip_pinned"); /// Interface scale (1 = 100 %). - ui_scale: f32 = 1.0, PAGE_INTERFACE, accept(0.5..=2.5); + ui_scale: f32 = 1.0, PAGE_APPEARANCE, accept(0.5..=2.5); /// Interface font: an installed font family (or a font file), or empty for the built-in one /// (#1290). Menus, dialogs and labels use it; glyphs it lacks fall back as before. - ui_font: String = String::new(), PAGE_INTERFACE; + ui_font: String = String::new(), PAGE_APPEARANCE; /// While a dialog or a sketch is open, OK and Cancel in the view's top right corner, with the /// axis view moved down below them (#6709). Off by default. - view_confirm_corner: bool = false, PAGE_INTERFACE => check_id("prefs.view_confirm_corner"); + view_confirm_corner: bool = false, PAGE_LAYOUT => check_id("prefs.view_confirm_corner"); /// A thin bar along the window's bottom edge: what is being edited, the open sketch's state /// and the design's units (#7243). Off by default. - status_bar: bool = false, PAGE_INTERFACE => check_id("prefs.status_bar"); + status_bar: bool = false, PAGE_LAYOUT => check_id("prefs.status_bar"); /// The selection's length, area or distance in the view's bottom right corner (#7000). Off by /// default. - quick_measure: bool = false, PAGE_INTERFACE => check_id("prefs.quick_measure"); + quick_measure: bool = false, PAGE_GRAPHICS => check_id("prefs.quick_measure"); /// Put the feature history to the left of the browser instead of below the viewport. - timeline_left: bool = false, PAGE_INTERFACE; + timeline_left: bool = false, PAGE_LAYOUT; /// Measured outer width; the contents react to this width without a names mode. - timeline_left_width: f32 = 130.0, PAGE_INTERFACE; + timeline_left_width: f32 = 130.0, PAGE_LAYOUT; /// Share the browser column, vertically, instead of a separate left column. - timeline_under_browser: bool = false, PAGE_INTERFACE; + timeline_under_browser: bool = false, PAGE_LAYOUT; /// Compact history: the browser lists each component's features in history order with the /// marker, and neither timeline is shown. - history_in_browser: bool = false, PAGE_INTERFACE; + history_in_browser: bool = false, PAGE_LAYOUT; /// Fold the shared-column history to its header. - timeline_collapsed: bool = false, PAGE_INTERFACE; + timeline_collapsed: bool = false, PAGE_LAYOUT; /// The browser's share of the column's height when History sits under it, which the user /// drags at the seam between the two cards (#7371). - browser_share: f32 = DEFAULT_BROWSER_SHARE, PAGE_INTERFACE; + browser_share: f32 = DEFAULT_BROWSER_SHARE, PAGE_LAYOUT; /// Whether the browser beside the vertical history is collapsed to its rail. - timeline_left_browser_collapsed: bool = false, PAGE_INTERFACE; + timeline_left_browser_collapsed: bool = false, PAGE_LAYOUT; /// Whether the History at the left lists a sketch under the feature built from it. - history_sketches: HistorySketches = HistorySketches::default(), PAGE_INTERFACE; + history_sketches: HistorySketches = HistorySketches::default(), PAGE_LAYOUT; /// A Play History button in the History controls, between Previous and Next (#3688). Off /// gives the four controls History had before; right-clicking the controls toggles it too. - history_play_button: bool = true, PAGE_INTERFACE => check_id("prefs.history_play_button"); + history_play_button: bool = true, PAGE_LAYOUT => check_id("prefs.history_play_button"); /// The vertical history (left, or under the browser) starts with the active component's Bodies /// folder and the Origin, as the browser shows them (#6490). The bottom strip is unchanged. - timeline_origin_bodies: bool = false, PAGE_INTERFACE; + timeline_origin_bodies: bool = false, PAGE_LAYOUT; /// Width of the browser sidebar, which /// the user drags wider or narrower at its right edge. - browser_width: f32 = DEFAULT_BROWSER_WIDTH, PAGE_INTERFACE; + browser_width: f32 = DEFAULT_BROWSER_WIDTH, PAGE_LAYOUT; /// Render shell preference, independent of the document and Design history. render_shots_strip: bool = true; render_inspector_side: String = "right".into(); @@ -1155,23 +1248,23 @@ preference_settings! { render_library_recent: std::collections::VecDeque<(String, String)> = Default::default(); /// Draw the parts browser on a translucent card over the 3D view instead of a column of its /// own, so the workspace keeps the whole window width. - browser_float: bool = false, PAGE_INTERFACE; + browser_float: bool = false, PAGE_LAYOUT; /// The browser card folded to its header with the chevron in that header, kept from one run /// to the next like the toolbar's pins (#8692). - browser_collapsed: bool = false, PAGE_INTERFACE; + browser_collapsed: bool = false, PAGE_LAYOUT; /// Whether the browser card is always shown or kept out of sight until the pointer comes to /// it, as the navigation bar's "Hide until hovered" does (#8692). - browser_show: crate::browser::BrowserShow = Default::default(), PAGE_INTERFACE; + browser_show: crate::browser::BrowserShow = Default::default(), PAGE_LAYOUT; /// Where command dialogs open: at the view's edge, or in the left column in place of the /// browser and the History there (#6707). - dialog_place: crate::dialog_column::DialogPlace = Default::default(), PAGE_INTERFACE; + dialog_place: crate::dialog_column::DialogPlace = Default::default(), PAGE_LAYOUT; /// The names the browser, dialogs and lists show for the origin planes (#6491). - origin_plane_names: OriginPlaneNames = OriginPlaneNames::default(), PAGE_INTERFACE; + origin_plane_names: OriginPlaneNames = OriginPlaneNames::default(), PAGE_DESIGN; /// When components' bodies sit in a Bodies folder in the browser (#4217). - browser_bodies_folders: BodiesFolders = BodiesFolders::Several, PAGE_INTERFACE; + browser_bodies_folders: BodiesFolders = BodiesFolders::Several, PAGE_LAYOUT; /// Each tree row's eye between its fold arrow and its radio, as before #8718, instead of in a /// column at the rows' right end. Off by default. - browser_eye_left: bool = false, PAGE_INTERFACE => check_id("prefs.browser_eye_left"); + browser_eye_left: bool = false, PAGE_LAYOUT => check_id("prefs.browser_eye_left"); /// The wheel zooms toward the point under the cursor (else toward the view's centre). zoom_to_cursor: bool = true, PAGE_NAVIGATION => check("Zoom to the cursor", "The wheel zooms toward the point under the cursor (off: toward the view's center)"); /// Speed multiplier for wheel zoom, independent of the 3D mouse. @@ -1204,33 +1297,51 @@ preference_settings! { stand_up_unknown: StandUpUnknown = StandUpUnknown::default(), PAGE_DESIGN; /// Extrude: with a profile picked, a face, vertex or parallel plane picked off its plane /// switches the extent to To Object (off: picks only add profiles, as before). - extrude_auto_to_object: bool = false, PAGE_DESIGN => check("Extrude to a picked target", "In Extrude, with a profile picked, picking a face, vertex or parallel plane off its plane switches the extent to To Object"); + extrude_auto_to_object: bool = false, PAGE_TOOLS => check("Extrude to a picked target", "In Extrude, with a profile picked, picking a face, vertex or parallel plane off its plane switches the extent to To Object"); + /// Extrude turns the view to isometric each time it opens, so the depth can be seen (#9176). + /// Off (the default): the view stays as it is. + extrude_view_isometric: bool = false, PAGE_TOOLS => check_id("prefs.extrude_view_isometric"); + /// With `extrude_view_isometric`: when Extrude closes (OK or Cancel) the view goes back to + /// where it was before it turned (#9176). Off (the default): it stays isometric. + extrude_view_return: bool = false, PAGE_TOOLS => check_id("prefs.extrude_view_return"); /// Feature windows choose Join, New Body or Cut from what their material touches (`op_auto`, /// #6901). Off: they keep the operation they open with (Extrude its own Join or Cut), as before. - auto_operation: bool = true, PAGE_DESIGN => check_id("prefs.auto_operation"); + auto_operation: bool = true, PAGE_TOOLS => check_id("prefs.auto_operation"); /// Placing a Single hole snaps to the middle of the face and the centres of its round edges /// (`hole_place`). Off by default: a hole goes where it is clicked. - hole_snaps: bool = false, PAGE_DESIGN => check("Snap to face and edge centres"); + hole_snaps: bool = false, PAGE_TOOLS => check("Snap to face and edge centres"); /// A placed Single hole has a handle that drags it, and a pick elsewhere on a face that has a /// hole moves the hole there (`hole_place`). Off by default: a second pick on its face takes /// the hole out, and a hole is moved by taking it out and picking again. - hole_moves: bool = false, PAGE_DESIGN => check("Move holes by clicking or dragging"); + hole_moves: bool = false, PAGE_TOOLS => check("Move holes by clicking or dragging"); /// The browser's Origin folder starts hidden in designs that don't keep its eye themselves. hide_origin: bool = false, PAGE_DESIGN => check_tr("ui.hide_origin_by_default"); /// Handles on the preview of a feature placed on a face (Rest, Boss, Snap Fit): a marker that /// drags it across the model and a ring that turns it. Off by default: the feature is then placed /// by its click only, as before, and its Position input takes faces only. - placement_handles: bool = false, PAGE_DESIGN => check("Placement handles", "Handles on the preview of a Rest, Boss or Snap Fit: drag the marker to move it across the model, turn it by its ring"); + placement_handles: bool = false, PAGE_TOOLS => check("Placement handles", "Handles on the preview of a Rest, Boss or Snap Fit: drag the marker to move it across the model, turn it by its ring"); /// The files a design names (linked designs, font files) may be on any network location, for /// organisations that keep their designs on file servers. Off (the default): only on the /// servers in `network_hosts`, or one the user allows when asked (`network_prompt`). - network_locations: bool = false, PAGE_DESIGN => check_tr("prefs.network_locations"); + network_locations: bool = false, PAGE_GENERAL => check_tr("prefs.network_locations"); /// The servers the files a design names may be on ("Always allow this server"). - network_hosts: Vec = Vec::new(), PAGE_DESIGN; + network_hosts: Vec = Vec::new(), PAGE_GENERAL; /// What a middle-button double-click in the view does: `home`, `fit` or `off`. middle_double_click: String = "home".into(), PAGE_NAVIGATION; + /// A view chosen on the navigator (a cube face, corner or arrow, an axis tip) also frames the + /// whole model, as Home does (#10091). Off by default: the view turns in place, keeping its + /// target and zoom. + navigator_fit: bool = false, PAGE_NAVIGATION; + /// The view cube's directional arrows orbit the camera the way the arrow points, as in + /// Fusion and Inventor (`invert_navigator_arrows`, #10254). Off by default: an arrow rotates + /// the cube toward the side it sits on, so the arrow on the left swings the camera left. + invert_navigator_arrows: bool = false, PAGE_NAVIGATION; + /// How much of the view the model fills when Home, Fit, an opened design or a navigator + /// view change frames it (0.1 to 1; #10091): 1, the default, fills the room between the + /// panels, the compass and the navigation bar, as before. + home_fit_size: f32 = 1.0, PAGE_NAVIGATION, accept(0.1..=1.0); /// Colour vision mode (#778): `off`, `protan`, `deutan` or `tritan`. - color_vision: crate::theme::ColorVision = crate::theme::ColorVision::Off, PAGE_INTERFACE; + color_vision: crate::theme::ColorVision = crate::theme::ColorVision::Off, PAGE_APPEARANCE; /// Check for updates at start and once a day (docs/updates.md). check_updates: bool = true, 0; /// When the last update check ran, and the versions skipped or put off (`update::Memo`). @@ -1269,19 +1380,19 @@ preference_settings! { custom_slicers: Vec = Vec::new(); /// AI features (docs/agent-chat.md): the master switch. Off: no AI button, shortcut, menu /// entry or panel, and nothing is looked for or started. - ai_features: bool = true, PAGE_AI; + ai_features: bool = true, PAGE_ASSISTANT; /// The Assistant button in the application bar (the shortcut and View menu entry stay). - ai_toolbar_button: bool = true, PAGE_AI; + ai_toolbar_button: bool = true, PAGE_ASSISTANT; /// An agent's slow commands over the control channel (MCP) compute off the window thread, /// so the window stays live while they run (#1989, [`crate::control_job`]). Off: they run on /// the window thread, and the window waits for each, as before (the default). - agent_off_thread: bool = false, PAGE_AI; + agent_off_thread: bool = false, PAGE_ASSISTANT; /// Let AI agents connect: the localhost control channel (MCP) opens on the default port at /// start-up, unless `--control`/`SOLIDCRAFT_CONTROL_PORT` name one. Read at start-up. - agent_control: bool = true, PAGE_AI; + agent_control: bool = true, PAGE_ASSISTANT; /// Add SolidCraft's MCP server to the AI apps installed on this computer at start-up /// ([`crate::ai_connect`]); off takes the entries out again. - connect_ai_apps: bool = true, PAGE_AI; + connect_ai_apps: bool = true, PAGE_ASSISTANT; /// Agents the user added (the installed ones are found by themselves). API keys are never /// here: they live in the system keychain. agents: Vec = Vec::new(); @@ -1292,26 +1403,26 @@ preference_settings! { /// When SolidCraft starts: connect the agent started last (`agent_last`, else the one a new /// conversation starts with), and open the panel (#7009). Off by default: an agent starts /// only when a message is sent or Connect is pressed, and the panel opens when asked for. - agent_connect_at_start: bool = false, PAGE_AI; - agent_chat_open_at_start: bool = false, PAGE_AI; + agent_connect_at_start: bool = false, PAGE_ASSISTANT; + agent_chat_open_at_start: bool = false, PAGE_ASSISTANT; /// How much the agent may do without asking (`ask`, `edits`, `all`), and per agent. - ai_permissions: crate::agent_chat::agents::Mode = crate::agent_chat::agents::Mode::Edits, PAGE_AI; - agent_permissions: std::collections::BTreeMap = Default::default(), PAGE_AI; + ai_permissions: crate::agent_chat::agents::Mode = crate::agent_chat::agents::Mode::Edits, PAGE_ASSISTANT; + agent_permissions: std::collections::BTreeMap = Default::default(), PAGE_ASSISTANT; /// The model last chosen in the chat, per agent: put back when the agent offers it again. agent_models: std::collections::BTreeMap = Default::default(); /// "Always allow" answers: per agent, the tools it may use without asking. agent_always_allow: std::collections::BTreeMap> = Default::default(); /// The side the panel docks on (`left` or `right`), its width, and whether it is folded to a strip. - agent_chat_side: String = "right".into(), PAGE_AI; - agent_chat_width: f32 = 380.0, PAGE_AI; - agent_chat_minimized: bool = false, PAGE_AI; + agent_chat_side: String = "right".into(), PAGE_ASSISTANT; + agent_chat_width: f32 = 380.0, PAGE_ASSISTANT; + agent_chat_minimized: bool = false, PAGE_ASSISTANT; /// Whether the chat is in its own window instead of the side panel, and that window's place /// and size as the system last reported them (`[x, y, width, height]`, points; `x` can be /// negative or past the main screen when the window sits on another one). - agent_chat_popped: bool = false, PAGE_AI; - agent_chat_window: Option<[f32; 4]> = None, PAGE_AI; + agent_chat_popped: bool = false, PAGE_ASSISTANT; + agent_chat_window: Option<[f32; 4]> = None, PAGE_ASSISTANT; /// Changes the agent may make for one message; 0 is no limit. - agent_chat_turn_limit: u32 = crate::agent_chat::gate::DEFAULT_TURN_LIMIT, PAGE_AI; + agent_chat_turn_limit: u32 = crate::agent_chat::gate::DEFAULT_TURN_LIMIT, PAGE_ASSISTANT; // ---- Sketch / selection modifier bindings (classic defaults; alternate mapping is opt-in) ---- /// Modifier that suppresses sketch auto-snap while drawing or dragging a sketch point. @@ -1319,6 +1430,9 @@ preference_settings! { suppress_sketch_snap: ModifierCombo = ModifierCombo::CTRL_ALT, PAGE_SKETCH; /// Temporary horizontal/vertical guides from hovered centres and edge midpoints. sketch_tracking_guides: bool = true, PAGE_SKETCH; + /// While a drawing tool waits for a point, hovered sketch points (ends, corners, free + /// points) start tracking guides too (#9105). Off: only centres and midpoints, as before. + sketch_tracking_points: bool = true, PAGE_SKETCH; /// Snap a drawing point to where the two most recent centre or midpoint guides cross. /// Off: the guides stay visual and the cursor does not move (#8694). sketch_recent_snap: bool = false, PAGE_SKETCH; @@ -1338,9 +1452,16 @@ preference_settings! { /// A sketch tool's shortcut pressed again before the first point steps to the tool's next /// creation mode (#2459). Off (the default): the key starts the tool again, as it always has. shortcut_cycles_modes: bool = false, PAGE_SKETCH; + /// A click on the ribbon button of the sketch tool that is running ends the tool, so clicks + /// in the view select again (#10151). Off: the click starts the tool again, as before. + ribbon_click_ends_tool: bool = true, PAGE_SKETCH => check_id("prefs.ribbon_click_ends_tool"); /// Opening or editing a sketch turns the view to face it (the default). Off: the view stays as /// it is, and Look At faces the sketch when asked. sketch_open_look_at: bool = true, PAGE_SKETCH; + /// A sketch started on a face of a body in another component than the active one activates + /// that component first, so the sketch belongs to it (`sketch.create` `activate_part`, + /// #10215). Off (the default): the sketch belongs to the active component, as before. + sketch_face_activates_part: bool = false, PAGE_SKETCH => check_id("prefs.sketch_face_activates_part"); /// Where the view goes when a sketch ends (#7038). Keep (the default) leaves it as it is. sketch_exit_view: SketchExitView = SketchExitView::default(), PAGE_SKETCH; /// A shape drawn in a sketch gets all its dimensions at once: the typed values, and every @@ -1364,6 +1485,10 @@ preference_settings! { /// release and end there (#7247); clicks still draw a chain. Off (the default): a drag is a /// click that moved on the way, placing a point where it is let go (#2448). line_drag_draws_one: bool = false, PAGE_SKETCH => check_id("prefs.line_drag_draws_one"); + /// In the Line tool, leaving the chain's last point and resting back on it switches to a + /// tangent arc, and the same again back to lines (#9489). Off (the default): resting there + /// does nothing, and tangent arcs are the Arc tool's. + line_hover_tangent_arc: bool = false, PAGE_SKETCH => check_id("prefs.line_hover_tangent_arc"); /// Include hidden geometry and a small pointer neighbourhood in Select Other. select_other_hidden: bool = true, PAGE_SKETCH => check_id("prefs.select_other_hidden"); /// When true: Shift+click adds without removing; Primary toggles. @@ -1380,7 +1505,7 @@ preference_settings! { /// fixed screen size at every zoom, as before. hide_constraint_glyphs_zoomed_out: bool = true, PAGE_SKETCH => check_id("prefs.hide_constraint_glyphs_zoomed_out"); /// Drive Joints shows the moved parts' old place as a ghost (its Show ghost box, #5905). - drive_joints_ghost: bool = true, PAGE_DESIGN; + drive_joints_ghost: bool = true, PAGE_TOOLS; /// Plugin ids turned off (Preferences › Plugins). An installed plugin whose id is absent is on. disabled_plugins: std::collections::BTreeSet = std::collections::BTreeSet::new(), PAGE_PLUGINS; } @@ -1525,6 +1650,14 @@ pub fn open_at(app: &mut SolveApp, page: usize) { app.prefs_window.section = page.min(PAGES.len() - 1); } +/// The sidebar is drawn before the selected page: defer its Profiles action until that page +/// callback, and leave any search before showing General's existing profile controls. +pub(crate) fn open_profiles(app: &mut SolveApp) { + clear_search(app); + open_at(app, PAGE_GENERAL); + app.prefs_window.land_on = Some(PAGE_GENERAL); +} + /// Every setting the dialog changes, for Cancel and Restore Defaults. #[derive(Clone)] struct Snapshot { @@ -1656,6 +1789,54 @@ fn apply_pending(app: &mut SolveApp) -> bool { app.prefs_window.snapshot.as_ref().is_some_and(|s| !s.matches(app)) } +/// The saved-settings keys whose values the snapshot holds, as [`SolveApp::prefs`] writes them. +/// While the dialog is open every writer of the settings file saves these instead of the live +/// values, so a change that was never applied can't reach disk through the periodic save, a +/// quit or a window close (#9730). Keys the dialog can't change keep their live values. +pub(crate) fn held_overlay(app: &SolveApp) -> Option> { + let s = app.prefs_window.snapshot.as_ref()?; + let mut prefs = s.prefs.clone(); + // Same bookkeeping rule as `restore`: a check that finished while the dialog was open stays. + prefs.update.clone_from(&app.preferences.update); + let mut shortcut_box = app.sbox.prefs(); + if let Some(o) = shortcut_box.as_object_mut() { + o.insert("pinned".into(), serde_json::json!(s.favourites)); + } + let mut profiles = app.settings_profiles.prefs(); + if let Some(o) = profiles.as_object_mut() { + o.insert("active".into(), serde_json::json!(s.profile)); + } + let mut shortcuts = serde_json::Map::new(); + for (id, keys) in &s.keys { + shortcuts.insert(id.clone(), crate::keymap::keys_value(keys)); + } + // Bound outside the `json!` macro: the ui-containers guard reads `.shadow` inside a macro + // as egui's popup shorthand. + let shadow = s.shadow; + let mut m = serde_json::Map::new(); + for (key, value) in [ + ("dark", serde_json::json!(s.dark)), + ("grid", serde_json::json!(s.grid)), + ("navigation_bar", serde_json::json!(s.navigation_bar)), + ("tool_cursor", serde_json::json!(s.tool_cursor)), + ("perspective", serde_json::json!(s.perspective)), + ("pick_bodies", serde_json::json!(s.pick_bodies)), + ("auto_project", serde_json::json!(s.auto_project)), + ("visual_style", serde_json::json!(s.style)), + ("shortcut_box", shortcut_box), + ("shortcuts", serde_json::Value::Object(shortcuts)), + ("toolbar", s.toolbar.clone()), + ("settings_profiles", profiles), + ("autosave_minutes", serde_json::json!(s.autosave)), + ("ground_shadow", serde_json::json!(shadow)), + ("agent_cursor", serde_json::json!(s.agent)), + ("preferences", serde_json::to_value(&prefs).unwrap_or_default()), + ] { + m.insert(key.to_string(), value); + } + Some(m) +} + /// The colour theme (#2255): Dark, Light, System, the built-in theme files and the imported /// ones, with Import…, Export… (the theme on now as a complete file, to start a new one from) /// and Remove (an imported theme). Like the other preferences, a choice applies at once. @@ -1681,7 +1862,9 @@ fn theme_row(app: &mut SolveApp, ui: &mut egui::Ui) { let palette = match id.as_str() { "light" | "system" => Tokens::light(), "dark" => Tokens::dark(), - _ => crate::theme_files::find(id, &app.preferences.custom_themes).map(crate::theme_files::resolve).unwrap_or_else(Tokens::dark), + _ => crate::theme_files::find(id, &app.preferences.custom_themes) + .map(|f| resolved_theme(&app.preferences, f)) + .unwrap_or_else(Tokens::dark), }; let custom = app.preferences.custom_themes.iter().any(|t| t.id == *id); let note = app.tr(if id == "system" { @@ -1699,6 +1882,12 @@ fn theme_row(app: &mut SolveApp, ui: &mut egui::Ui) { true, ui_kit::PreferenceFigure::Theme { palette: palette.into(), system: id == "system" }, ); + if crate::theme_files::find(id, &app.preferences.custom_themes) + .is_some_and(|file| !crate::theme_options::offered(file, crate::theme_options::PROVIDERS).is_empty()) + { + ui_kit::theme_options_badge(ui, response.rect, app.tr("prefs.theme_options_badge")); + crate::scenario::publish_rect(&format!("prefs:theme_options_badge:{id}"), response.rect); + } crate::scenario::publish_target(&format!("prefs:theme_tile:{id}"), response.rect); crate::scenario::publish_target(&format!("prefs:theme:{id}"), response.rect); if !following && app.preferences.theme == *id { @@ -1886,14 +2075,16 @@ fn defaults(app: &mut SolveApp, page: usize) { app.autosave_minutes = 5.0; set_autosave(app); } - PAGE_INTERFACE => { + PAGE_APPEARANCE => { app.prefs_window.follow.launch = None; app.ui.dark = true; + } + PAGE_LAYOUT => { app.ui.layout = app.preferences.layout; app.ui.show_navigation_bar = true; } - PAGE_AI => app.ui.agent_cursor = Default::default(), - PAGE_NAVIGATION => app.ui.tool_cursor = false, + PAGE_ASSISTANT => app.ui.agent_cursor = Default::default(), + PAGE_TOOLS => app.ui.tool_cursor = false, PAGE_DESIGN => {} PAGE_SHORTCUTS => crate::keymap::reset(app, None), PAGE_GRAPHICS => { @@ -1989,12 +2180,14 @@ pub fn apply(app: &mut SolveApp, ctx: &egui::Context) { // A theme file's palette, in place of its base's (dark or light); the visuals follow. A // followed file (#7573) wins while it is followed and has read completely once. follow_theme_file(app, ctx); - let file = app.prefs_window.follow.theme().or_else(|| crate::theme_files::find(&app.preferences.theme, &app.preferences.custom_themes)); - if let Some(f) = file { - app.ui.dark = f.base.is_dark(); + let file = active_theme_file(app); + let custom = crate::theme::prepare_tabs(ctx, file, file.and_then(|f| app.preferences.theme_options.get(&f.id)), || { + file.map(|f| effective_theme_options(&app.preferences, f).0).unwrap_or_default() + }); + if let Some(custom) = &custom { + app.ui.dark = custom.dark; } - let follows_system = app.preferences.theme == "system" && file.is_none(); - let custom = file.map(|f| crate::theme::Custom { dark: f.base.is_dark(), tokens: crate::theme_files::resolve(f) }); + let follows_system = app.preferences.theme == "system" && custom.is_none(); if crate::theme::set_custom(custom) { app.styled = false; } @@ -2355,14 +2548,23 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { .apply_enabled(pending); let window = if capturing { window.keep_open_on_escape() } else { window }; let mut selected = app.prefs_window.section; - let (closed, action) = window.show_pages_with_header(ctx, &mut selected, WINDOW_PAGES, app, search_header, clear_search, |ui| { - if ui_kit::button(ui, crate::i18n::tr("ui.restore_defaults"), ui_kit::Button::Normal) - .on_hover_text(crate::i18n::tr("ui.reset_the_settings_on_this_page")) - .clicked() - { - button = Some("defaults"); - } - }); + let (closed, action) = window.show_pages_with_header( + ctx, + &mut selected, + WINDOW_PAGES, + app, + search_header, + clear_search, + Some((Tokens::get().control_height * 3.0, crate::settings_profiles::sidebar)), + |ui| { + if ui_kit::button(ui, crate::i18n::tr("ui.restore_defaults"), ui_kit::Button::Normal) + .on_hover_text(crate::i18n::tr("ui.reset_the_settings_on_this_page")) + .clicked() + { + button = Some("defaults"); + } + }, + ); if let Some(rect) = crate::scenario::rect_of("dialog:page_body") { crate::scenario::publish_rect("prefs:page_body", rect); } @@ -2443,19 +2645,19 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) { // Paint only the pages that match. A first pass is invisible, so the 3D mouse rows and the // agent list cannot draw on top of the setting that did match. let mut keep = vec![false; WINDOW_PAGES.len()]; - for (index, item) in WINDOW_PAGES.iter().enumerate() { - crate::prefs_search::begin_page(ui, index, crate::i18n::tr(item.label)); + for (index, _) in WINDOW_PAGES.iter().enumerate() { + crate::prefs_search::begin_page(ui, index, &page_path(index)); let before = crate::prefs_search::hits(ui); let mut probe = crate::prefs_search::detached_child(ui, ("prefs-search-probe", index), true); draw_page(app, &mut probe, index, t); keep[index] = crate::prefs_search::hits(ui) > before; } crate::prefs_search::rewind_search_layout(ui); - for (index, item) in WINDOW_PAGES.iter().enumerate() { + for (index, _) in WINDOW_PAGES.iter().enumerate() { if !keep.get(index).copied().unwrap_or(false) { continue; } - crate::prefs_search::begin_page(ui, index, crate::i18n::tr(item.label)); + crate::prefs_search::begin_page(ui, index, &page_path(index)); let before = crate::prefs_search::hits(ui); let mut child = crate::prefs_search::detached_child(ui, ("prefs-search-page", index), false); draw_page(app, &mut child, index, t); @@ -2465,8 +2667,8 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) { ui.label(egui::RichText::new(crate::i18n::tr("prefs.search_empty")).size(t.font_body).color(t.form_dim)); } } else { - let label = WINDOW_PAGES.get(app.prefs_window.section).map(|item| crate::i18n::tr(item.label)).unwrap_or(""); - crate::prefs_search::begin_page(ui, app.prefs_window.section, label); + let label = page_path(app.prefs_window.section); + crate::prefs_search::begin_page(ui, app.prefs_window.section, &label); draw_page(app, ui, app.prefs_window.section, t); } crate::prefs_search::store_nav(ui); @@ -2474,11 +2676,20 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) { fn draw_page(app: &mut SolveApp, ui: &mut egui::Ui, index: usize, t: &Tokens) { match index { - PAGE_SHORTCUTS => crate::keymap::page(app, ui), - PAGE_AI => crate::agent_chat::settings::page(app, ui), + PAGE_SHORTCUTS => { + ui_kit::settings_heading_with_tag(ui, app.tr("prefs.shortcuts"), "", Some(crate::settings_profiles::tag)); + crate::keymap::page(app, ui); + } + PAGE_ASSISTANT => crate::agent_chat::settings::page(app, ui), PAGE_PLUGINS => crate::plugins::page(app, ui), PAGE_GENERAL => general::show(app, ui), - PAGE_INTERFACE => interface::interface(app, ui, t), + PAGE_APPEARANCE => interface::appearance(app, ui, t), + PAGE_LAYOUT => interface::layout(app, ui), + PAGE_TOOLS => tools::show(app, ui), + PAGE_MOUSE3D => { + ui_kit::settings_heading(ui, app.tr("prefs.structure.mouse3d"), app.tr("prefs.structure.mouse3d_note")); + space_mouse_section(app, ui); + } PAGE_DESIGN => design::show(app, ui), PAGE_NAVIGATION => navigation::show(app, ui, t), PAGE_GRAPHICS => graphics::show(app, ui), @@ -2488,16 +2699,13 @@ fn draw_page(app: &mut SolveApp, ui: &mut egui::Ui, index: usize, t: &Tokens) { } fn page_index(name: &str) -> Option { - match name.to_ascii_lowercase().as_str() { - "selection" | "sketch & selection" | "sketch and selection" => Some(PAGE_SKETCH), - "interface/navigation bar" => Some(PAGE_INTERFACE), - "assistant" => Some(PAGE_AI), - "shortcuts" => Some(PAGE_SHORTCUTS), - _ => PAGES.iter().position(|x| x.eq_ignore_ascii_case(name)), - } + PAGES + .iter() + .position(|page| page.eq_ignore_ascii_case(name)) + .or_else(|| PAGE_ALIASES.iter().find_map(|(alias, page)| alias.eq_ignore_ascii_case(name).then_some(*page))) } -/// The 3D mouse's settings under Navigation, drawn with the same settings kit as the sections above +/// The 3D mouse page's settings, drawn with the same settings kit as the sections above /// it: whether it moves the view and shows in it, where the picture goes, its speed, the driver and /// calibration, each axis, the picture with its dead zone, and what its buttons do. A section like the /// others, so it folds with its header and stays as the user last left it. @@ -2505,6 +2713,30 @@ fn space_mouse_section(app: &mut SolveApp, ui: &mut egui::Ui) { ui_kit::settings_section(ui, "space_mouse", crate::i18n::tr("ui.3d_mouse_section"), |ui| space_mouse_rows(app, ui)); } +/// A device's own app on this computer driving the view (the navigation bridge): on or off, its +/// port, and whether an app is connected. Part of the 3D mouse section (`space_mouse_rows`). +fn nav_bridge(app: &mut SolveApp, ui: &mut egui::Ui) { + let (label, tip, port) = (app.tr("ui.nav_bridge").to_owned(), app.tr("ui.nav_bridge_tip").to_owned(), app.tr("ui.nav_bridge_port").to_owned()); + let status = match (&app.space_mouse.bridge, app.preferences.nav_bridge_enabled) { + (_, false) => None, + (Some(name), true) => Some((format!("{} · {name}", app.tr("ui.nav_bridge_connected")), false)), + (None, true) => Some((app.tr("ui.nav_bridge_waiting").to_owned(), true)), + }; + ui.horizontal_wrapped(|ui| { + ui_kit::checkbox(ui, &label, &mut app.preferences.nav_bridge_enabled).on_hover_text(&tip); + if let Some((status, waiting)) = &status { + ui_kit::settings_badge(ui, status, *waiting); + } + }); + ui_kit::settings_form(ui, FORM_LABEL_WIDTH, |ui| { + ui_kit::row(ui, &port, |ui| { + ui.add_enabled_ui(app.preferences.nav_bridge_enabled, |ui| { + ui_kit::number_field(ui, &mut app.preferences.nav_bridge_port, 1..=u16::MAX, 1.0, ""); + }); + }); + }); +} + /// The label column the Navigation page's forms share (`prefs/navigation.rs`). const FORM_LABEL_WIDTH: f32 = 150.0; @@ -2631,7 +2863,7 @@ fn space_mouse_rows(app: &mut SolveApp, ui: &mut egui::Ui) { }); // A device whose own app drives the view instead (the navigation bridge). ui_kit::divider(ui, "nav_bridge"); - navigation::nav_bridge(app, ui); + nav_bridge(app, ui); // The rarely needed settings sit behind one disclosure, closed until the reader opens it. ui_kit::section(ui, "space_mouse_advanced", crate::i18n::tr_label("Advanced"), false, |ui| { ui_kit::settings_form(ui, FORM_LABEL_WIDTH, |ui| { @@ -3043,37 +3275,88 @@ fn button_job_picker( /// Returns them all, the open page, the offer (and whether it is the note), the theme ids and the /// followed file (`theme_followed`, `theme_follow_error`). pub fn control(app: &mut SolveApp, p: &serde_json::Value) -> Result { - let mut rest = p.as_object().cloned().unwrap_or_default(); - if let Some(page) = rest.remove("open") { - let name = page.as_str().unwrap_or("General"); - let i = page_index(name).ok_or_else(|| format!("no page `{name}` ({})", PAGES.join(", ")))?; + // Stage every fallible preference operation before dialogs, document actions or writes. + let mut rest = p.as_object().cloned().ok_or("preferences must be an object")?; + let page = rest + .remove("open") + .map(|page| { + let name = page.as_str().unwrap_or("General"); + page_index(name) + .map(|i| (i, name.eq_ignore_ascii_case("Interface/Navigation bar"))) + .ok_or_else(|| format!("no page `{name}` ({})", PAGES.join(", "))) + }) + .transpose()?; + let button = rest.remove("button"); + if let Some(b) = button.as_ref().and_then(|v| v.as_str()) + && !["ok", "cancel", "apply", "defaults"].contains(&b) + { + return Err(format!("unknown button `{b}` (ok, cancel, apply, defaults)")); + } + let stand_up = rest.remove("stand_up").and_then(|v| v.as_bool()); + let mut staged = app.preferences.clone(); + let mut follow = app.prefs_window.follow.clone(); + let imported = rest + .remove("theme_import") + .map(|value| { + let theme = match value { + serde_json::Value::String(text) => crate::theme_files::parse(&text)?, + other => crate::theme_files::from_value(&other)?, + }; + let id = crate::theme_files::add(&mut staged.custom_themes, theme)?; + staged.theme = id.clone(); + Ok::<_, String>(id) + }) + .transpose()?; + if let Some(id) = rest.remove("theme_remove") { + let id = id.as_str().unwrap_or_default(); + let i = staged.custom_themes.iter().position(|t| t.id == id).ok_or_else(|| format!("no imported theme `{id}`"))?; + let gone = staged.custom_themes.remove(i); + staged.theme_options.remove(id); + if staged.theme == id { + staged.theme = if gone.base.is_dark() { "dark" } else { "light" }.into(); + } + } + match rest.remove("theme_follow") { + Some(value) => { + if !FOLLOW_AVAILABLE { + return Err(FOLLOW_UNAVAILABLE.into()); + } + let path = value.as_str().ok_or("`theme_follow` must be a theme file's path, or an empty string to stop following")?; + staged.theme_follow = crate::theme_files::follow_path(path)?; + follow.launch = None; + let _ = follow.poll(&staged.theme_follow, f64::NEG_INFINITY, true); + } + None if rest.contains_key("theme") => { + staged.theme_follow.clear(); + follow.launch = None; + let _ = follow.poll("", f64::NEG_INFINITY, true); + } + None => {} + } + let export = rest.remove("theme_export").and_then(|v| v.as_bool()).unwrap_or(false); + let new = prepare_preferences(app, &staged, followed_theme(&staged, &follow), &serde_json::Value::Object(rest.clone()))?; + let stopped_following = !app.prefs_window.follow.path(&app.preferences.theme_follow).is_empty() && follow.path(&new.theme_follow).is_empty(); + if stand_up == Some(true) && app.stand_up.is_none() { + return Err("no file is waiting to be stood up".into()); + } + if let Some((i, navbar)) = page { open_at(app, i); - app.prefs_window.navbar_scroll = name.eq_ignore_ascii_case("Interface/Navigation bar"); + app.prefs_window.navbar_scroll = navbar; if app.prefs_window.snapshot.is_none() { app.prefs_window.snapshot = Some(Snapshot::take(app)); } } - match rest.remove("stand_up").and_then(|v| v.as_bool()) { + match stand_up { Some(true) => crate::stand_up::stand_up(app)?, Some(false) => crate::stand_up::leave(app), None => {} } - let imported = match rest.remove("theme_import") { - Some(v) => Some(import_theme(app, &v)?), - None => None, - }; - if let Some(id) = rest.remove("theme_remove") { - remove_theme(app, id.as_str().unwrap_or_default())?; + if stopped_following { + // Restore the selected theme's dark/light mode even when its saved id did not change. + stop_following(app); } - match rest.remove("theme_follow") { - Some(v) => set_theme_follow(app, &v, FOLLOW_AVAILABLE)?, - // Choosing a theme stops following a file, as clicking a theme tile does. - None if rest.contains_key("theme") => stop_following(app), - None => {} - } - let export = rest.remove("theme_export").and_then(|v| v.as_bool()).unwrap_or(false); - let button = rest.remove("button"); - let mut out = set(app, &serde_json::Value::Object(rest))?; + app.prefs_window.follow = follow; + let mut out = apply_preferences(app, new, &serde_json::Value::Object(rest))?; match button.as_ref().and_then(|b| b.as_str()) { Some("ok") => close(app, false), Some("cancel") => close(app, true), @@ -3088,6 +3371,9 @@ pub fn control(app: &mut SolveApp, p: &serde_json::Value) -> Result Result Result { + let mut base = value.clone(); + if let Some(object) = base.as_object_mut() { + object.remove("options"); + object.remove("overlays"); + object.remove("tabs"); + } + let mut theme: crate::theme_files::ThemeFile = serde_json::from_value(base).map_err(|e| e.to_string())?; + if let Err(error) = theme.set_tabs(value) { + if strict { + return Err(error); + } + log::warn!("preferences: theme `{}` has invalid tab surfaces ({error}); keeping its base palette", theme.id); + } + if let Err(error) = theme.set_option_tables(value) { + if strict { + return Err(error); + } + log::warn!("preferences: theme `{}` has invalid option tables ({error}); keeping its base palette", theme.id); + } + Ok(theme) +} + +fn themes_that_read<'de, D: serde::Deserializer<'de>>(saved: D) -> Result, D::Error> { + Vec::::deserialize(saved)?.iter().map(|value| theme_from_preferences(value, false).map_err(serde::de::Error::custom)).collect() +} + /// The saved 3D mouse calibrations that still read. One this version can't read (a hand edit, a /// format from another release) is dropped on its own, with a log line, not with all the others. fn calibrations_that_read<'de, D: serde::Deserializer<'de>>( @@ -3201,11 +3516,11 @@ fn looks_like_theme(text: &str) -> bool { text.trim_start().starts_with('{') && text.contains(crate::theme_files::FORMAT) } -/// Ctrl/Cmd+V on the Interface page with a theme file's text on the clipboard imports it, like +/// Ctrl/Cmd+V on the Appearance page with a theme file's text on the clipboard imports it, like /// Import… with a file (#8226). Called before the global Copy and Paste shortcuts, which would /// otherwise paste copied components; the paste is taken out of the input once it is a theme's. pub fn take_theme_paste(app: &mut SolveApp, ctx: &egui::Context) { - if !app.prefs_window.open || app.prefs_window.section != PAGE_INTERFACE { + if !app.prefs_window.open || app.prefs_window.section != PAGE_APPEARANCE { return; } let Some(text) = ctx.input(|i| i.events.iter().find_map(|e| if let egui::Event::Paste(t) = e { Some(t.clone()) } else { None })) else { @@ -3277,6 +3592,7 @@ fn remove_theme(app: &mut SolveApp, id: &str) -> Result<(), String> { return Err(format!("no imported theme `{id}`")); }; let gone = app.preferences.custom_themes.remove(i); + app.preferences.theme_options.remove(id); if app.preferences.theme == id { select_theme(app, if gone.base.is_dark() { "dark" } else { "light" }); } @@ -3285,14 +3601,255 @@ fn remove_theme(app: &mut SolveApp, id: &str) -> Result<(), String> { /// The theme on now as a complete theme file, a starting point for a new one. fn current_theme_file(app: &SolveApp) -> serde_json::Value { - theme_file(app, &app.preferences.theme, app.ui.dark) + match active_theme_file(app) { + Some(f) => export_theme_file(f), + None => theme_file(app, &app.preferences.theme, app.ui.dark), + } +} + +fn active_theme_file(app: &SolveApp) -> Option<&crate::theme_files::ThemeFile> { + followed_theme(&app.preferences, &app.prefs_window.follow) + .or_else(|| crate::theme_files::find(&app.preferences.theme, &app.preferences.custom_themes)) +} + +fn followed_theme<'a>(prefs: &Prefs, follow: &'a crate::theme_files::Follow) -> Option<&'a crate::theme_files::ThemeFile> { + (!follow.path(&prefs.theme_follow).is_empty()).then(|| follow.theme()).flatten() +} + +/// Preserve authored definitions; themes predating options retain their complete snapshot export. +fn export_theme_file(file: &crate::theme_files::ThemeFile) -> serde_json::Value { + if !file.options.is_empty() || !file.overlays.is_empty() || !file.tabs.is_empty() || file.needs_newer() { + crate::theme_files::export_file(file) + } else { + crate::theme_files::export(&file.name, file.base, &crate::theme_files::resolve(file)) + } +} + +fn effective_theme_options( + prefs: &Prefs, + file: &crate::theme_files::ThemeFile, +) -> (BTreeMap, Vec) { + use crate::theme_files::{OptionKind, OptionNoticeKind, OptionValue}; + use solidcraft_engine::schema::ui_prefs::{ThemeOptionNotice, ThemeOptionNoticeKind as Kind}; + let mut saved = BTreeMap::new(); + let mut notices = Vec::new(); + let definitions = crate::theme_files::ordered_options(file); + for (id, value) in prefs.theme_options.get(&file.id).into_iter().flatten() { + let definition = definitions.iter().find(|o| &o.id == id); + if let Some(definition) = definition + && let OptionKind::Range { min, max, .. } = &definition.kind + && value.as_f64().is_some_and(|n| n < f64::from(*min) || n > f64::from(*max)) + { + notices.push(ThemeOptionNotice { id: id.clone(), kind: Kind::OutOfRange }); + continue; + } + match serde_json::from_value::(value.clone()) { + Ok(value) => { + saved.insert(id.clone(), value); + } + Err(_) => { + notices.push(ThemeOptionNotice { id: id.clone(), kind: if definition.is_some() { Kind::WrongType } else { Kind::UnknownOption } }) + } + } + } + let (values, problems) = crate::theme_files::effective_values(file, &saved); + notices.extend(problems.into_iter().map(|notice| ThemeOptionNotice { + id: notice.id, + kind: match notice.kind { + OptionNoticeKind::UnknownOption => Kind::UnknownOption, + OptionNoticeKind::WrongType => Kind::WrongType, + OptionNoticeKind::UnknownChoice => Kind::UnknownChoice, + OptionNoticeKind::NonFinite => Kind::NonFinite, + OptionNoticeKind::Clamped => Kind::OutOfRange, + }, + })); + notices.sort_by(|a, b| a.id.cmp(&b.id)); + (values, notices) +} + +fn resolved_theme(prefs: &Prefs, file: &crate::theme_files::ThemeFile) -> Tokens { + crate::theme_files::resolve_with(file, &effective_theme_options(prefs, file).0) +} + +fn theme_option_text(text: &crate::theme_files::OptionText, language: &str, fallback: &str) -> String { + text.get(language).or_else(|| text.get("en")).map(String::as_str).unwrap_or(fallback).to_owned() +} + +fn theme_option_rows<'a>( + file: &'a crate::theme_files::ThemeFile, + language: &str, + providers: &[crate::theme_options::Provider], +) -> Vec> { + use crate::theme_options::{Availability, offered, provider}; + let locale = crate::i18n::Locale::for_language(language); + offered(file, providers) + .into_iter() + .map(|definition| { + let provider = provider(&definition.id, providers); + ui_kit::ThemeOptionRow { + definition, + label: provider + .map(|p| locale.text(p.label).to_owned()) + .unwrap_or_else(|| theme_option_text(&definition.label, language, &definition.id)), + help: provider.map(|p| locale.text(p.help).to_owned()).unwrap_or_else(|| theme_option_text(&definition.help, language, "")), + choices: match &definition.kind { + crate::theme_files::OptionKind::Choice { values, .. } => { + values.iter().map(|v| theme_option_text(&v.label, language, &v.id)).collect() + } + _ => Vec::new(), + }, + reason: provider.and_then(|p| match (p.availability)() { + Availability::Available => None, + Availability::Unavailable(reason) => Some(locale.text(reason).to_owned()), + }), + } + }) + .collect() +} + +fn theme_options_row(app: &mut SolveApp, ui: &mut egui::Ui) { + let Some(file) = active_theme_file(app) else { return }; + let rows = theme_option_rows(file, crate::i18n::effective_language(&app.preferences.language), crate::theme_options::PROVIDERS); + if rows.is_empty() { + return; + } + let (mut values, _) = effective_theme_options(&app.preferences, file); + let before = values.clone(); + let reset = ui_kit::theme_options_list(ui, &file.id, &file.name, &rows, &mut values); + let changes: BTreeMap<_, _> = values.into_iter().filter(|(id, value)| before.get(id) != Some(value)).collect(); + if reset || !changes.is_empty() { + let update = if reset { json!({}) } else { json!(changes) }; + let params = json!({"theme_options": {(file.id.clone()): update}}); + // Same atomic validation/merge and followed-file semantics as the control channel. + // No button, page selection or dialog is added by this live preference update. + if let Err(error) = control(app, ¶ms) { + app.set_status(error, true); + } + } +} + +/// Only explicitly supplied entries are checked. Stale saved entries survive unrelated changes. +fn merge_theme_options(prefs: &mut Prefs, followed: Option<&crate::theme_files::ThemeFile>, update: &serde_json::Value) -> Result<(), String> { + use crate::theme_files::{OptionKind, OptionValue}; + let themes = update.as_object().ok_or("`theme_options` must be a map of theme ids to option maps")?; + // Apply legacy keys first so a canonical entry in the same request always wins. + let aliases = themes.iter().filter(|(id, _)| crate::theme_files::legacy_gruvbox(id).is_some()); + let canonical = themes.iter().filter(|(id, _)| crate::theme_files::legacy_gruvbox(id).is_none()); + for (requested_id, update) in aliases.chain(canonical) { + let theme_id = crate::theme_files::legacy_gruvbox(requested_id).map_or(requested_id.as_str(), |(family, _)| family); + let key = format!("theme_options.{theme_id}"); + let file = followed.filter(|f| f.id == theme_id).or_else(|| crate::theme_files::find(theme_id, &prefs.custom_themes)); + let mode = THEMES.iter().any(|(id, _)| *id == theme_id); + if file.is_none() && !mode { + return Err(format!("`{key}`: unknown theme")); + } + let options = update.as_object().ok_or_else(|| format!("`{key}` must be an option map"))?; + if options.is_empty() { + prefs.theme_options.remove(theme_id); + continue; + } + for (id, value) in options { + let key = format!("{key}.{id}"); + let definition = file + .and_then(|file| crate::theme_files::ordered_options(file).into_iter().find(|o| &o.id == id)) + .ok_or_else(|| format!("`{key}`: unknown option"))?; + if value.is_null() { + if let Some(saved) = prefs.theme_options.get_mut(theme_id) { + saved.remove(id); + } + continue; + } + let parsed: OptionValue = serde_json::from_value(value.clone()).map_err(|_| format!("`{key}`: wrong type"))?; + let valid = match (&definition.kind, &parsed) { + (OptionKind::Bool { .. }, OptionValue::Bool(_)) => true, + (OptionKind::Choice { values, .. }, OptionValue::Choice(id)) => values.iter().any(|v| &v.id == id), + (OptionKind::Range { min, max, .. }, OptionValue::Num(n)) => { + // Check the original JSON number too, before f32 conversion rounds at a bound. + n.is_finite() && value.as_f64().is_some_and(|n| n.is_finite() && n >= f64::from(*min) && n <= f64::from(*max)) + } + _ => false, + }; + if !valid { + return Err(format!("`{key}`: wrong type, unknown choice or out of range")); + } + prefs.theme_options.entry(theme_id.to_owned()).or_default().insert(id.clone(), value.clone()); + } + if prefs.theme_options.get(theme_id).is_some_and(BTreeMap::is_empty) { + prefs.theme_options.remove(theme_id); + } + } + Ok(()) +} + +fn theme_options_reply(app: &SolveApp) -> solidcraft_engine::schema::ui_prefs::ThemeOptionsReply { + use crate::theme_files::{OptionKind, OptionValue}; + use solidcraft_engine::schema::ui_prefs::{ThemeChoice, ThemeOptionDef, ThemeOptionKind, ThemeOptionValue, ThemeOptionsReply}; + let Some(file) = active_theme_file(app) else { return ThemeOptionsReply::default() }; + let text = |texts: &crate::theme_files::OptionText, fallback: &str| { + texts + .get(crate::i18n::effective_language(&app.preferences.language)) + .or_else(|| texts.get("en")) + .map(String::as_str) + .unwrap_or(fallback) + .to_string() + }; + let (values, notices) = effective_theme_options(&app.preferences, file); + ThemeOptionsReply { + theme_option_defs: crate::theme_files::ordered_options(file) + .into_iter() + .map(|o| { + let standard = crate::theme_options::standard(&o.id); + let provider = crate::theme_options::provider(&o.id, crate::theme_options::PROVIDERS); + let availability = provider.map(|p| (p.availability)()); + ThemeOptionDef { + id: o.id.clone(), + kind: match &o.kind { + OptionKind::Bool { default } => ThemeOptionKind::Bool { default: *default }, + OptionKind::Choice { default, values } => ThemeOptionKind::Choice { + default: default.clone(), + values: values.iter().map(|v| ThemeChoice { id: v.id.clone(), label: text(&v.label, &v.id) }).collect(), + }, + OptionKind::Range { default, min, max, step, unit } => { + ThemeOptionKind::Range { default: *default, min: *min, max: *max, step: *step, unit: unit.clone() } + } + }, + label: provider.map(|p| app.tr(p.label).to_owned()).unwrap_or_else(|| text(&o.label, &o.id)), + help: provider.map(|p| app.tr(p.help).to_owned()).unwrap_or_else(|| text(&o.help, "")), + parent: o.parent.clone(), + advanced: o.advanced, + section: o.section.clone(), + standard, + available: !standard || availability == Some(crate::theme_options::Availability::Available), + reason: match availability { + Some(crate::theme_options::Availability::Unavailable(reason)) => Some(app.tr(reason).to_owned()), + None if standard => Some("not implemented".into()), + _ => None, + }, + } + }) + .collect(), + theme_option_values: values + .into_iter() + .map(|(id, value)| { + ( + id, + match value { + OptionValue::Bool(v) => ThemeOptionValue::Bool(v), + OptionValue::Choice(v) => ThemeOptionValue::Choice(v), + OptionValue::Num(v) => ThemeOptionValue::Num(v), + }, + ) + }) + .collect(), + theme_option_notices: notices, + } } /// Export the tile the user chose, without applying it or changing the dialog snapshot. fn theme_file(app: &SolveApp, id: &str, system_dark: bool) -> serde_json::Value { - use crate::theme_files::{Base, export, find, resolve}; + use crate::theme_files::{Base, export, find}; match find(id, &app.preferences.custom_themes) { - Some(f) => export(&f.name, f.base, &resolve(f)), + Some(f) => export_theme_file(f), None if id == "dark" || (id == "system" && system_dark) => export("Dark", Base::Dark, &crate::theme::Tokens::dark()), None => export("Light", Base::Light, &crate::theme::Tokens::light()), } @@ -3303,6 +3860,7 @@ fn theme_file(app: &SolveApp, id: &str, system_dark: bool) -> serde_json::Value /// calibration that can't be used as it is (a hand edit) is dropped, so the device is read as it /// reports rather than spinning the view. pub fn migrate(mut p: Prefs) -> Prefs { + migrate_gruvbox(&mut p); // Every saved file carries the old default limit; it reads as no limit. if p.agent_chat_turn_limit == crate::agent_chat::gate::OLD_DEFAULT_TURN_LIMIT { p.agent_chat_turn_limit = 0; @@ -3319,6 +3877,9 @@ pub fn migrate(mut p: Prefs) -> Prefs { if !accepted::wheel_zoom_speed(&p) { p.wheel_zoom_speed = Prefs::default().wheel_zoom_speed; } + if !accepted::home_fit_size(&p) { + p.home_fit_size = Prefs::default().home_fit_size; + } if p.orbit_cursor { p.orbit_around = OrbitAround::Cursor; p.orbit_cursor = false; @@ -3337,6 +3898,25 @@ pub fn migrate(mut p: Prefs) -> Prefs { p } +/// Fold old contrast cards into their family without replacing unrelated saved options. +/// An active old card still names the selected appearance; explicit control updates run after +/// this migration, so their contrast choice (including a reset) takes precedence. +pub(crate) fn migrate_gruvbox(p: &mut Prefs) { + let selected = crate::theme_files::legacy_gruvbox(&p.theme); + for (old, family, _) in crate::theme_files::GRUVBOX_ALIASES { + if let Some(options) = p.theme_options.remove(old) { + let canonical = p.theme_options.entry(family.into()).or_default(); + for (id, value) in options { + canonical.entry(id).or_insert(value); + } + } + } + if let Some((family, contrast)) = selected { + p.theme = family.into(); + p.theme_options.entry(family.into()).or_default().insert("contrast".into(), json!(contrast)); + } +} + /// Drop plugin ids this page would not show, and keep at most [`crate::plugins::MAX_DISABLED`], /// so a hand-edited preferences file cannot carry a huge or nonsense list. `set` still refuses /// those instead of dropping them. @@ -3392,8 +3972,13 @@ pub fn lenient(saved: &serde_json::Value) -> Prefs { serde_json::from_value(cur).map(migrate).map(limit_disabled_plugins).map(limit_toolbar_tabs).unwrap_or_default() } -/// Set preferences from JSON (control channel); returns them all. -pub fn set(app: &mut SolveApp, p: &serde_json::Value) -> Result { +/// Validate a proposed preference state without applying it or performing writes. +fn prepare_preferences( + app: &SolveApp, + base: &Prefs, + followed: Option<&crate::theme_files::ThemeFile>, + p: &serde_json::Value, +) -> Result { for key in ["joint_rotation_coarse", "joint_rotation_fine"] { if let Some(value) = p.get(key) && value.as_f64().is_none_or(|v| !v.is_finite() || !(0.001..=180.0).contains(&v)) @@ -3401,13 +3986,26 @@ pub fn set(app: &mut SolveApp, p: &serde_json::Value) -> Result = values + .iter() + .enumerate() + .map(|(i, value)| theme_from_preferences(value, true).map_err(|error| format!("`custom_themes[{i}]`: {error}"))) + .collect::>()?; + o.insert(k.clone(), serde_json::to_value(themes).map_err(|e| e.to_string())?); + } else if k != "theme_options" { + o.insert(k.clone(), v.clone()); + } } } // Saved preferences read an unknown mode as the default; asked for here, it is an error. @@ -3429,10 +4027,17 @@ pub fn set(app: &mut SolveApp, p: &serde_json::Value) -> Result Result>().join(", "))); + if new.language != crate::i18n::SYSTEM && !crate::i18n::is_supported(&new.language) { + return Err(format!( + "`language` must be `{}` or one of: {}", + crate::i18n::SYSTEM, + crate::i18n::LOCALES.iter().map(|locale| locale.id).collect::>().join(", ") + )); } if new.ui_font != app.preferences.ui_font && !new.ui_font.trim().is_empty() && crate::fonts::ui_font(&new.ui_font).is_none() { return Err(format!("`ui_font`: `{}` is not an installed font family or a readable font file", new.ui_font.trim())); @@ -3505,6 +4114,9 @@ pub fn set(app: &mut SolveApp, p: &serde_json::Value) -> Result Result Result { + let new = prepare_preferences(app, &app.preferences, followed_theme(&app.preferences, &app.prefs_window.follow), p)?; + apply_preferences(app, new, p) +} + +/// All fallible preference checks finish before any application or file write. +fn apply_preferences(app: &mut SolveApp, new: Prefs, p: &serde_json::Value) -> Result { + let file = crate::theme_files::find(&new.theme, &new.custom_themes); + if (new.theme != app.preferences.theme || new.custom_themes != app.preferences.custom_themes) && new.theme != "system" { app.ui.dark = file.map_or(new.theme == "dark", |f| f.base.is_dark()); } let units_changed = new.default_units != app.preferences.default_units; @@ -3549,8 +4173,7 @@ pub fn set(app: &mut SolveApp, p: &serde_json::Value) -> Result) -> crate::scenario::Harness { let mut h = crate::scenario::Harness::new(); @@ -4367,7 +5002,7 @@ mod tests { p.space_mouse_ghost_position = SpaceMouseGhost::LeftWindow; p.space_mouse_in_view = true; h.call("ui.resize", serde_json::json!({"width": 1600, "height": 4000})); - crate::prefs::open_at(&mut h.app, crate::prefs::PAGE_NAVIGATION); + crate::prefs::open_at(&mut h.app, crate::prefs::PAGE_MOUSE3D); h.frames(36); open_advanced(&mut h); h @@ -4388,7 +5023,7 @@ mod tests { h.app.space_mouse.device = Some("hid 046d:c62b".into()); h.app.space_mouse.drivers = Some(crate::space_mouse::DriversPresent { spacenavd: true, direct: true, denied: false }); h.call("ui.resize", serde_json::json!({"width": 1600, "height": 4000})); - crate::prefs::open_at(&mut h.app, crate::prefs::PAGE_NAVIGATION); + crate::prefs::open_at(&mut h.app, crate::prefs::PAGE_MOUSE3D); h.frames(36); let drawn = |name: &str| crate::scenario::rect_of(&format!("dialog:row:{name}")).is_some(); let (driver, axes) = (crate::i18n::tr("ui.3d_mouse_driver"), crate::i18n::tr("ui.3d_mouse_button").replace("{number}", "1")); @@ -4469,7 +5104,7 @@ mod tests { h.step(&serde_json::json!({"click": {"handle": "dialog:cancel"}})).unwrap(); h.frames(6); assert!(!h.app.prefs_window.open, "the dialog closed"); - crate::prefs::open_at(&mut h.app, crate::prefs::PAGE_NAVIGATION); + crate::prefs::open_at(&mut h.app, crate::prefs::PAGE_MOUSE3D); h.frames(6); assert!(!h.app.space_mouse.restore_pending, "the question is not asked again on its own"); assert_eq!(h.app.preferences, before); @@ -4485,6 +5120,7 @@ mod tests { scroll_to_and_click(&mut h, "dialog:button::Restore defaults"); scroll_to_and_click(&mut h, "dialog:button::Restore defaults"); assert!(!h.app.space_mouse.restore_pending && section_is_at_defaults(&h.app)); + h.call("ui.prefs", json!({"button":"apply"})); let saved = h.app.prefs(); let mut next = crate::scenario::Harness::new(); next.app.preferences.space_mouse_speed = 2.5; @@ -4517,7 +5153,7 @@ mod tests { card.frames(5); // Preferences'. let mut prefs = harness(); - crate::prefs::open_at(&mut prefs.app, crate::prefs::PAGE_NAVIGATION); + crate::prefs::open_at(&mut prefs.app, crate::prefs::PAGE_MOUSE3D); prefs.frames(6); prefs.frames(30); assert!(prefs.app.space_mouse.calibrating.is_none()); @@ -4555,7 +5191,7 @@ mod tests { h.app.space_mouse.device = Some(device.into()); h.app.space_mouse.drivers = Some(crate::space_mouse::DriversPresent { spacenavd: true, direct, denied: false }); h.call("ui.resize", serde_json::json!({"width": 1600, "height": 4000})); - crate::prefs::open_at(&mut h.app, crate::prefs::PAGE_NAVIGATION); + crate::prefs::open_at(&mut h.app, crate::prefs::PAGE_MOUSE3D); h.frames(6); h.frames(30); open_advanced(&mut h); @@ -4593,7 +5229,7 @@ mod tests { // The axis assignment is changed, so its reset button is enabled and drawn. h.app.preferences.space_mouse_axis_source = [1, 0, 2, 3, 4, 5]; h.call("ui.resize", serde_json::json!({"width": 1600, "height": 4000})); - crate::prefs::open_at(&mut h.app, crate::prefs::PAGE_NAVIGATION); + crate::prefs::open_at(&mut h.app, crate::prefs::PAGE_MOUSE3D); h.frames(6); scroll_to_and_click(&mut h, &format!("dialog:section:{}", crate::i18n::Locale::for_language(catalog.id).text("Advanced"))); h.frames(10); @@ -4734,7 +5370,7 @@ mod tests { h.app.space_mouse.device = None; h.app.space_mouse.drivers = Some(crate::space_mouse::DriversPresent { spacenavd: true, direct: false, denied: false }); h.call("ui.resize", serde_json::json!({"width": 1600, "height": 4000})); - crate::prefs::open_at(&mut h.app, crate::prefs::PAGE_NAVIGATION); + crate::prefs::open_at(&mut h.app, crate::prefs::PAGE_MOUSE3D); h.frames(5); assert!(crate::scenario::rect_of("dialog:section:3D mouse").is_some(), "the section is drawn with the kit's header"); assert!(crate::scenario::rect_of("dialog:section:3D mouse:count").is_none()); @@ -5039,36 +5675,57 @@ mod tests { ("default_units", json!("ft"), PAGE_DESIGN), ("length_decimals", json!(5), PAGE_DESIGN), ("angle_decimals", json!(4), PAGE_DESIGN), - ("theme", json!("light"), PAGE_INTERFACE), - ("ui_scale", json!(1.25), PAGE_INTERFACE), - ("ui_font", json!("test font"), PAGE_INTERFACE), - ("layout", json!("compact"), PAGE_INTERFACE), - ("ribbon_labels", json!(false), PAGE_INTERFACE), - ("persistent_ribbon_pressed", json!(false), PAGE_INTERFACE), - ("viewport_analysis_status", json!(false), PAGE_INTERFACE), - ("viewport_section_status", json!(false), PAGE_INTERFACE), - ("viewport_status_summary", json!(false), PAGE_INTERFACE), - ("sketch_tab_always", json!(true), PAGE_INTERFACE), - ("ribbon_reveal", json!(true), PAGE_INTERFACE), - ("dialog_buttons", json!("top"), PAGE_INTERFACE), - ("toast_place", json!("centre"), PAGE_INTERFACE), - ("ribbon_icon_grid", json!(true), PAGE_INTERFACE), - ("ribbon_menus_skip_pinned", json!(true), PAGE_INTERFACE), - ("timeline_left", json!(true), PAGE_INTERFACE), - ("timeline_under_browser", json!(true), PAGE_INTERFACE), - ("history_sketches", json!("under_feature"), PAGE_INTERFACE), - ("history_play_button", json!(false), PAGE_INTERFACE), - ("browser_float", json!(true), PAGE_INTERFACE), - ("browser_collapsed", json!(true), PAGE_INTERFACE), - ("browser_show", json!("hover"), PAGE_INTERFACE), - ("dialog_place", json!("left_column"), PAGE_INTERFACE), - ("color_vision", json!("protan"), PAGE_INTERFACE), - ("viewport_theme", json!("light"), PAGE_INTERFACE), - ("viewport_background", json!([255, 255, 255]), PAGE_INTERFACE), - ("view_confirm_corner", json!(true), PAGE_INTERFACE), - ("status_bar", json!(true), PAGE_INTERFACE), - ("browser_eye_left", json!(true), PAGE_INTERFACE), - ("quick_measure", json!(true), PAGE_INTERFACE), + ("theme", json!("light"), PAGE_APPEARANCE), + ("ui_scale", json!(1.25), PAGE_APPEARANCE), + ("ui_font", json!("test font"), PAGE_APPEARANCE), + ("layout", json!("compact"), PAGE_LAYOUT), + ("ribbon_labels", json!(false), PAGE_LAYOUT), + ("persistent_ribbon_pressed", json!(false), PAGE_LAYOUT), + ("viewport_analysis_status", json!(false), PAGE_GRAPHICS), + ("viewport_section_status", json!(false), PAGE_GRAPHICS), + ("viewport_status_details", json!(false), PAGE_GRAPHICS), + ("viewport_status_summary", json!(false), PAGE_GRAPHICS), + ("sketch_tab_always", json!(true), PAGE_LAYOUT), + ("keep_work_on_tab_switch", json!(true), PAGE_LAYOUT), + ("ribbon_reveal", json!(true), PAGE_LAYOUT), + ("dialog_buttons", json!("top"), PAGE_LAYOUT), + ("toast_place", json!("centre"), PAGE_LAYOUT), + ("ribbon_icon_grid", json!(true), PAGE_LAYOUT), + ("ribbon_menus_skip_pinned", json!(true), PAGE_LAYOUT), + ("timeline_left", json!(true), PAGE_LAYOUT), + ("timeline_under_browser", json!(true), PAGE_LAYOUT), + ("history_sketches", json!("under_feature"), PAGE_LAYOUT), + ("history_play_button", json!(false), PAGE_LAYOUT), + ("browser_float", json!(true), PAGE_LAYOUT), + ("browser_collapsed", json!(true), PAGE_LAYOUT), + ("browser_show", json!("hover"), PAGE_LAYOUT), + ("dialog_place", json!("left_column"), PAGE_LAYOUT), + ("color_vision", json!("protan"), PAGE_APPEARANCE), + ("viewport_theme", json!("light"), PAGE_APPEARANCE), + ("viewport_background", json!([255, 255, 255]), PAGE_APPEARANCE), + ("view_confirm_corner", json!(true), PAGE_LAYOUT), + ("status_bar", json!(true), PAGE_LAYOUT), + ("browser_eye_left", json!(true), PAGE_LAYOUT), + ("quick_measure", json!(true), PAGE_GRAPHICS), + ("origin_plane_names", json!("top_front_right"), PAGE_DESIGN), + ("direct_interaction", json!(true), PAGE_TOOLS), + ("hole_snaps", json!(true), PAGE_TOOLS), + ("hole_moves", json!(true), PAGE_TOOLS), + ("extrude_auto_to_object", json!(true), PAGE_TOOLS), + ("extrude_view_isometric", json!(true), PAGE_TOOLS), + ("extrude_view_return", json!(true), PAGE_TOOLS), + ("auto_operation", json!(false), PAGE_TOOLS), + ("placement_handles", json!(true), PAGE_TOOLS), + ("assembly_move_handles", json!(false), PAGE_TOOLS), + ("joint_rotation_coarse", json!(12.0), PAGE_TOOLS), + ("joint_rotation_fine", json!(0.2), PAGE_TOOLS), + ("drive_joints_ghost", json!(false), PAGE_TOOLS), + ("measure_axis_distances", json!(false), PAGE_TOOLS), + ("network_locations", json!(true), PAGE_GENERAL), + ("network_hosts", json!(["files.studio"]), PAGE_GENERAL), + ("nav_bridge_enabled", json!(true), PAGE_MOUSE3D), + ("nav_bridge_port", json!(9876), PAGE_MOUSE3D), + ("space_mouse_speed", json!(2.0), PAGE_MOUSE3D), ("nav", json!("blender"), PAGE_NAVIGATION), ("look_at_anim", json!(false), PAGE_NAVIGATION), ("selection_shift_add_only", json!(true), PAGE_SKETCH), @@ -5078,8 +5735,11 @@ mod tests { ("suppress_sketch_snap", json!(ModifierCombo::NONE), PAGE_SKETCH), ("sketch_state_icons", json!(false), PAGE_SKETCH), ("sketch_tracking_guides", json!(false), PAGE_SKETCH), + ("sketch_tracking_points", json!(false), PAGE_SKETCH), ("sketch_align_points", json!(true), PAGE_SKETCH), ("sketch_recent_snap", json!(true), PAGE_SKETCH), + ("line_hover_tangent_arc", json!(true), PAGE_SKETCH), + ("ribbon_click_ends_tool", json!(false), PAGE_SKETCH), ("msaa", json!(8), PAGE_GRAPHICS), ("single_surface_analysis", json!(false), PAGE_GRAPHICS), ]; @@ -5102,7 +5762,7 @@ mod tests { for name in ["Sketch", "Selection", "Sketch & selection"] { assert_eq!(page_index(name), Some(PAGE_SKETCH)); } - assert_eq!(page_index("Assistant"), Some(PAGE_AI)); + assert_eq!(page_index("Assistant"), Some(PAGE_ASSISTANT)); assert_eq!(page_index("Shortcuts"), Some(PAGE_SHORTCUTS)); let mut app = SolveApp::new(Default::default(), Default::default()); app.ui.agent_cursor.show = false; @@ -5112,7 +5772,7 @@ mod tests { assert!(!app.ui.agent_cursor.show); assert!(app.ui.pick_bodies); assert!(!app.session.auto_project); - defaults(&mut app, PAGE_AI); + defaults(&mut app, PAGE_ASSISTANT); let expected = SolveApp::new(Default::default(), Default::default()).ui.agent_cursor.show; assert_eq!(app.ui.agent_cursor.show, expected); defaults(&mut app, PAGE_SKETCH); @@ -5120,6 +5780,141 @@ mod tests { assert!(app.session.auto_project); } + #[test] + fn preferences_structure_order_groups_and_unique_field_homes() { + assert_eq!( + PAGES, + [ + "General", + "Appearance", + "Layout", + "Graphics", + "Design", + "Sketch", + "Tools", + "Navigation", + "3D mouse", + "Shortcuts", + "Assistant", + "Plugins" + ] + ); + assert_eq!(WINDOW_PAGES.len(), PAGES.len()); + let groups: Vec<_> = WINDOW_PAGES.iter().enumerate().filter_map(|(i, p)| p.group.map(|g| (i, crate::i18n::tr(g)))).collect(); + assert_eq!(groups, [(PAGE_APPEARANCE, "Look"), (PAGE_DESIGN, "Work"), (PAGE_NAVIGATION, "Input"), (PAGE_ASSISTANT, "Extend")]); + let mut fields = std::collections::BTreeSet::new(); + for (field, page) in FIELD_PAGES { + assert!(fields.insert(field), "duplicate home for {field}"); + assert!(page.is_none_or(|p| p < PAGES.len()), "invalid page for {field}"); + } + let saved = serde_json::to_value(Prefs::default()).unwrap(); + assert_eq!(fields.len(), saved.as_object().unwrap().len(), "every stored field has exactly one metadata entry"); + for (i, name) in PAGES.iter().enumerate() { + assert_eq!(crate::i18n::tr(WINDOW_PAGES[i].label), *name); + assert_eq!(WINDOW_PAGES[i].handle, format!("prefs:page:{name}")); + } + } + + #[test] + fn preferences_all_canonical_and_legacy_pages_open_and_publish_real_alias_handles() { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.call("ui.resize", json!({"width":1600,"height":1200})); + for (name, index) in PAGES.iter().enumerate().map(|(i, n)| (*n, i)).chain(PAGE_ALIASES.iter().copied()) { + assert_eq!(page_index(name), Some(index)); + let reply = h.call("ui.prefs", json!({"open":name})); + assert!(reply["error"].is_null(), "{name}: {reply}"); + assert_eq!(reply["result"]["dialog"], PAGES[index]); + open_at(&mut h.app, index); + h.frames(4); + assert_eq!(h.app.prefs_window.section, index); + assert_eq!(crate::scenario::rect_of(&format!("prefs:page:{name}")), crate::scenario::rect_of(WINDOW_PAGES[index].handle)); + assert!(crate::scenario::rect_of(&format!("prefs:page:{name}")).is_some()); + } + for (alias, index) in PAGE_ALIASES { + open_at(&mut h.app, *index); + h.frames(4); + h.step(&json!({"click":{"handle":format!("prefs:page:{alias}")}})).unwrap(); + assert_eq!(h.app.prefs_window.section, *index, "{alias} is clickable"); + } + } + + #[test] + fn preferences_moved_controls_have_one_drawn_home() { + let cases = [ + ("Show tool at cursor", PAGE_TOOLS), + ("Direct interaction", PAGE_TOOLS), + ("Show X/Y/Z distances", PAGE_TOOLS), + ("Move holes by clicking or dragging", PAGE_TOOLS), + ("Extrude to a picked target", PAGE_TOOLS), + ("Turn the view to isometric when Extrude opens", PAGE_TOOLS), + ("Go back to the earlier view when Extrude closes", PAGE_TOOLS), + ("Placement handles", PAGE_TOOLS), + ("Navigate with the 3D mouse", PAGE_MOUSE3D), + ("Origin plane names", PAGE_DESIGN), + ("Status bar", PAGE_LAYOUT), + ("Color vision", PAGE_APPEARANCE), + ("Quick measurement in the view's corner", PAGE_GRAPHICS), + ("Show viewport analysis status", PAGE_GRAPHICS), + ("Show viewport section status", PAGE_GRAPHICS), + ("Live viewport details", PAGE_GRAPHICS), + ("Fold viewport status items", PAGE_GRAPHICS), + ("Allow network locations in designs", PAGE_GENERAL), + ("Print face highlight", PAGE_GENERAL), + ]; + let mut found = vec![Vec::new(); cases.len()]; + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + for page in 0..PAGES.len() { + open_at(&mut h.app, page); + h.frames(4); + for (i, (label, _)) in cases.iter().enumerate() { + if h.widget(label).is_some() { + found[i].push(page); + } + } + } + for ((label, page), actual) in cases.iter().zip(found) { + assert_eq!(actual, [*page], "{label} must have exactly one Preferences home"); + } + } + + #[test] + fn preferences_session_resets_follow_their_new_pages_only() { + for page in 0..PAGES.len() { + let mut app = SolveApp::new(Default::default(), Default::default()); + app.autosave_minutes = 9.0; + app.ui.dark = false; + app.prefs_window.follow.launch = Some("theme.json".into()); + app.ui.layout = crate::shell::LayoutMode::Docked; + app.ui.show_navigation_bar = false; + app.ui.tool_cursor = true; + app.ui.visual_style = 2; + app.ui.show_grid = false; + app.ui.ground_shadow = false; + app.ui.perspective = true; + app.session.auto_project = false; + app.ui.pick_bodies = true; + app.ui.agent_cursor.show = true; + app.keymap.custom.insert("solid.extrude".into(), vec!["X".into()]); + defaults(&mut app, page); + assert_eq!(app.autosave_minutes, if page == PAGE_GENERAL { 5.0 } else { 9.0 }); + assert_eq!(app.ui.dark, page == PAGE_APPEARANCE); + assert_eq!(app.prefs_window.follow.launch.is_none(), page == PAGE_APPEARANCE); + assert_eq!(app.ui.layout == crate::shell::LayoutMode::Floating, page == PAGE_LAYOUT); + assert_eq!(app.ui.show_navigation_bar, page == PAGE_LAYOUT); + assert_eq!(app.ui.tool_cursor, page != PAGE_TOOLS); + assert_eq!(app.ui.visual_style, if page == PAGE_GRAPHICS { 0 } else { 2 }); + assert_eq!(app.ui.show_grid, page == PAGE_GRAPHICS); + assert_eq!(app.ui.ground_shadow, page == PAGE_GRAPHICS); + assert_eq!(app.ui.perspective, page != PAGE_GRAPHICS); + assert_eq!(app.session.auto_project, page == PAGE_SKETCH); + assert_eq!(app.ui.pick_bodies, page != PAGE_SKETCH); + assert_eq!(app.ui.agent_cursor.show, page != PAGE_ASSISTANT); + assert_eq!(app.keymap.custom.is_empty(), page == PAGE_SHORTCUTS); + } + } + fn hole_preset_row(name: &str) -> serde_json::Value { serde_json::json!({ "name": name, "style": "counterbore", "extent": "distance", @@ -5243,7 +6038,7 @@ mod tests { let mut other = crate::SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); other.load_prefs(&app.prefs()); assert_eq!(other.preferences.ui_font, family); - super::defaults(&mut app, super::PAGE_INTERFACE); + super::defaults(&mut app, super::PAGE_APPEARANCE); assert_eq!(app.preferences.ui_font, ""); } @@ -5294,13 +6089,13 @@ mod tests { let saved = lenient(&json!({"browser_bodies_folders": bad, "theme": "light"})); assert_eq!((saved.browser_bodies_folders, saved.theme.as_str()), (BodiesFolders::Several, "light"), "{bad}"); } - h.call("ui.prefs", json!({"open": "Interface"})); + h.call("ui.prefs", json!({"open": "Layout"})); reveal_preference(&mut h, "dialog:Bodies folders:Always"); h.step(&json!({"click": {"handle": "dialog:Bodies folders:Always"}})).unwrap(); assert_eq!(h.app.preferences.browser_bodies_folders, BodiesFolders::Always); h.call("ui.prefs", json!({"button": "cancel"})); assert_eq!(h.app.preferences.browser_bodies_folders, BodiesFolders::Never, "Cancel puts it back"); - defaults(&mut h.app, PAGE_INTERFACE); + defaults(&mut h.app, PAGE_LAYOUT); assert_eq!(h.app.preferences.browser_bodies_folders, BodiesFolders::Several, "Interface's Restore Defaults"); } @@ -5440,7 +6235,7 @@ mod tests { let out = h.call("ui.prefs", json!({"theme": "gruvbox-dark-hard", "viewport_theme": "light"})); assert_eq!((out["result"]["dark"].as_bool(), out["result"]["viewport_dark"].as_bool()), (Some(true), Some(false)), "{out}"); h.frames(2); - let hard = crate::theme_files::find("gruvbox-dark-hard", &[]).map(crate::theme_files::resolve).unwrap(); + let hard = resolved_theme(&h.app.preferences, active_theme_file(&h.app).unwrap()); assert_eq!((Tokens::get().panel, Tokens::get().viewport_top), (hard.panel, Tokens::light().viewport_top)); // Asked for, an unknown value is refused; read from saved preferences, it is the default. @@ -5653,16 +6448,730 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + fn theme_options_fixture(base: &str, count: usize) -> serde_json::Value { + let fixtures: serde_json::Value = serde_json::from_str(include_str!("../tests/fixtures/theme-options.json")).unwrap(); + fixtures[format!("{base}-{count}")].clone() + } + + #[test] + fn theme_options_ui_counts_badges_more_and_reset_in_light_and_dark() { + for base in ["light", "dark"] { + for count in [0, 1, 3, 6, 12] { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.call("ui.resize", json!({"width":1600,"height":1100})); + let out = h.call("ui.prefs", json!({"theme_import":theme_options_fixture(base, count),"open":"Interface"})); + assert!(out["error"].is_null(), "{out}"); + h.frames(3); + assert_eq!(crate::scenario::rect_of("prefs:theme_options_header").is_some(), count > 0, "{base} {count}"); + let id = format!("options-{base}-{count}"); + assert_eq!(crate::scenario::rect_of(&format!("prefs:theme_options_badge:{id}")).is_some(), count > 0); + assert_eq!(crate::scenario::handle_at("prefs:theme_options_more").is_some(), count == 12); + let cells: std::collections::BTreeSet<_> = crate::scenario::rects("prefs:theme_option_cell:").into_iter().map(|(id, _)| id).collect(); + assert_eq!(cells.len(), count.min(10), "only ten top-level cells before More"); + assert_eq!( + crate::scenario::rect_of("dialog:hint:theme_option_section").is_some(), + count > 6, + "dim section labels only above six entries" + ); + assert!(crate::scenario::handle_at("prefs:theme_options_reset").is_none()); + if count == 0 { + let strip = crate::scenario::rect_of("dialog:hint:tab_accent").unwrap(); + let mut unknown = theme_options_fixture(base, count); + unknown["options"] = json!([{"id":"blur","type":"range","default":0}]); + h.call("ui.prefs", json!({"theme_import":unknown})); + h.frames(2); + assert!(crate::scenario::rect_of("prefs:theme_options_header").is_none()); + assert!(crate::scenario::rect_of(&format!("prefs:theme_options_badge:{id}")).is_none()); + assert_eq!(crate::scenario::rect_of("dialog:hint:tab_accent").unwrap(), strip, "unknown providers add no gap or notice"); + } + if count == 12 { + assert!(crate::scenario::rect_of("prefs:theme_option:outline").is_none()); + assert!(crate::scenario::rect_of("prefs:theme_option:extra").is_none(), "advanced is always under More"); + super::reveal_preference(&mut h, "prefs:theme_options_more"); + h.step(&json!({"click":{"handle":"prefs:theme_options_more"}})).unwrap(); + h.frames(2); + assert!(crate::scenario::rect_of("prefs:theme_option:outline").is_some()); + assert!(crate::scenario::rect_of("prefs:theme_option:extra").is_some()); + } + if count >= 3 { + super::reveal_preference(&mut h, "prefs:theme_option_switch:Warm accent"); + h.step(&json!({"click":{"handle":"prefs:theme_option_switch:Warm accent"}})).unwrap(); + h.frames(2); + assert_eq!(h.app.preferences.theme_options[&id]["warm"], true, "UI edits go through preference merging"); + assert!(crate::scenario::handle_at("prefs:theme_options_reset").is_some()); + super::reveal_preference(&mut h, "prefs:theme_options_reset"); + h.step(&json!({"click":{"handle":"prefs:theme_options_reset"}})).unwrap(); + h.frames(2); + assert!(!h.app.preferences.theme_options.contains_key(&id)); + assert!(crate::scenario::handle_at("prefs:theme_options_reset").is_none()); + } + } + } + } + + #[test] + fn theme_options_ui_short_composite_shares_row_with_narrow_control() { + for base in ["light", "dark"] { + for language in ["en", "de"] { + for width in [1600, 520] { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.call("ui.resize", json!({"width":width,"height":1100})); + let file = json!({"format":"solidcraft-theme/1","name":"Compact options","base":base,"options":[ + {"id":"shadow","type":"bool","default":true,"label":{"en":"Shadow","de":"Schatten"}}, + {"id":"softness","type":"range","default":5.5,"min":0,"max":10,"step":0.5,"unit":"px","parent":"shadow", + "label":{"en":"Softness","de":"Weichheit"}}, + {"id":"edges","type":"range","default":5.5,"min":0,"max":10,"step":0.5,"unit":"px", + "label":{"en":"Edges","de":"Kanten"}} + ]}); + let out = h.call("ui.prefs", json!({"theme_import":file,"open":"Interface","language":language})); + assert!(out["error"].is_null(), "{out}"); + h.frames(3); + let rect = |name: &str| crate::scenario::rect_of(name).unwrap(); + let parent = rect("prefs:theme_option:shadow"); + let child = rect("prefs:theme_option:softness"); + let composite = rect("prefs:theme_option_cell:shadow"); + let neighbour = rect("prefs:theme_option_cell:edges"); + let body = rect("prefs:page_body").expand(1.0); + assert!(body.contains_rect(composite) && body.contains_rect(neighbour), "{base} {language} width={width}"); + assert!(composite.contains_rect(parent) && composite.contains_rect(child)); + assert!(!composite.intersects(neighbour)); + if width == 1600 { + assert!( + (composite.top() - neighbour.top()).abs() < 1.0, + "short composite must share a normal Preferences row: {composite:?} {neighbour:?}" + ); + assert!(composite.right() < neighbour.left(), "declared left-to-right order"); + assert!((parent.top() - child.top()).abs() < 1.0); + assert!(parent.right() < child.left(), "parent and child share one compact cell"); + } else { + assert!(neighbour.top() > composite.bottom(), "a genuinely narrow page falls back to a full composite row"); + } + let labels = if language == "de" { ["Schatten", "Weichheit", "Kanten"] } else { ["Shadow", "Softness", "Edges"] }; + let parts = [ + rect(&format!("dialog:label:{}", labels[0])), + rect(&format!("prefs:theme_option_switch:{}", labels[0])), + rect(&format!("dialog:label:{}", labels[1])), + rect(&format!("dialog:slider:{}", labels[1])), + rect("prefs:theme_option_readout:softness"), + rect(&format!("dialog:label:{}", labels[2])), + rect(&format!("dialog:slider:{}", labels[2])), + rect("prefs:theme_option_readout:edges"), + ]; + for (i, part) in parts.iter().enumerate() { + assert!(body.contains_rect(*part), "{base} {language} width={width}: {part:?} outside {body:?}"); + for other in parts.iter().skip(i + 1) { + assert!(!part.intersects(*other), "{base} {language} width={width}: {part:?} overlaps {other:?}"); + } + } + assert!(rect(&format!("dialog:slider:{}", labels[1])).width() >= Tokens::get().space_24 * 2.0, "retain a usable slider track"); + let control = rect(&format!("dialog:control:{}", labels[2])); + let track = rect(&format!("dialog:slider_track:{}", labels[2])); + let readout = rect("prefs:theme_option_readout:edges"); + let t = Tokens::get(); + let natural_width = |text: &str| { + h.ctx.fonts_mut(|fonts| { + fonts.layout_no_wrap(text.to_owned(), egui::FontId::proportional(t.font_label), t.form_ink).size().x.ceil() + }) + }; + assert!((rect(&format!("dialog:label:{}", labels[2])).width() - natural_width(labels[2]) - t.space_4).abs() <= 1.0); + assert!((readout.width() - natural_width("5.5px")).abs() <= 1.0, "readout must retain its natural width"); + let reserved_readout = ["0px", "10px", "5.5px", "0.5px", "9.5px"].into_iter().map(natural_width).fold(0.0, f32::max); + assert!(neighbour.expand(1.0).contains_rect(control)); + assert!(control.contains_rect(track) && control.contains_rect(readout)); + assert!( + (track.width() - (control.width() - reserved_readout - t.space_8 - t.space_4)).abs() <= 1.0, + "standalone slider must fill the width left by its natural label and readout: {base} {language} width={width}: {control:?} {track:?} {readout:?}" + ); + } + } + } + } + + #[test] + fn theme_options_ui_wide_composite_dropdown_and_advanced_keep_order() { + for base in ["light", "dark"] { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.call("ui.resize", json!({"width":1600,"height":1100})); + h.call("ui.prefs", json!({"theme_import":theme_options_fixture(base, 12),"open":"Interface"})); + h.frames(3); + let rect = |id: &str| crate::scenario::rect_of(&format!("prefs:theme_option:{id}")).unwrap(); + assert!(rect("opacity").center().x < rect("warm").center().x); + assert!((rect("opacity").top() - rect("warm").top()).abs() < 1.0); + assert!(rect("palette").top() > rect("warm").top()); + assert!(rect("radius").top() > rect("palette").top(), "four choices occupy a full row"); + let composite = crate::scenario::rect_of("prefs:theme_option_cell:shadow").unwrap(); + assert!(composite.contains_rect(rect("shadow")) && composite.contains_rect(rect("softness"))); + assert!(crate::scenario::rect_of("prefs:theme_option_cell:softness").is_none(), "child is not an eleventh cell"); + super::reveal_preference(&mut h, "dialog:Palette:Warm"); + h.step(&json!({"click":{"handle":"dialog:Palette:Warm"}})).unwrap(); + h.frames(2); + let id = format!("options-{base}-12"); + assert_eq!(h.app.preferences.theme_options[&id]["palette"], "v2"); + super::reveal_preference(&mut h, "prefs:theme_option_switch:Shadow"); + h.step(&json!({"click":{"handle":"prefs:theme_option_switch:Shadow"}})).unwrap(); + h.frames(2); + assert_eq!(h.app.preferences.theme_options[&id]["shadow"], false); + let saved = h.app.preferences.theme_options[&id].clone(); + let track = crate::scenario::rect_of("dialog:slider_track:Softness").unwrap(); + h.step(&json!({"click":[track.center().x,track.center().y]})).unwrap(); + h.frames(2); + assert_eq!(h.app.preferences.theme_options[&id], saved, "inactive child is inert and retains its value"); + super::reveal_preference(&mut h, "prefs:theme_option:pattern"); + h.step(&json!({"click":{"handle":"dialog:Pattern"}})).unwrap(); + h.frames(2); + assert!(crate::scenario::handle_at("dialog:Pattern:Plain").is_some(), "six choices use a dropdown"); + h.step(&json!({"click":{"handle":"dialog:Pattern:Plain"}})).unwrap(); + h.frames(2); + assert_eq!(h.app.preferences.theme_options[&id]["pattern"], "v6"); + assert!(crate::scenario::rect_of("prefs:theme_option:extra").is_none()); + super::reveal_preference(&mut h, "prefs:theme_options_more"); + h.step(&json!({"click":{"handle":"prefs:theme_options_more"}})).unwrap(); + h.frames(2); + assert!(rect("extra").top() > rect("compact").top()); + assert!((rect("extra").top() - rect("outline").top()).abs() < 1.0); + } + } + + #[test] + fn theme_options_ui_opacity_live_reset_and_cancel_use_the_existing_path() { + for base in ["light", "dark"] { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + control(&mut h.app, &json!({"theme_import":theme_options_fixture(base, 1)})).unwrap(); + h.call("ui.prefs", json!({"open":"Interface"})); + h.frames(3); + let full = Tokens::get(); + let file = active_theme_file(&h.app).unwrap(); + let mut invalid_saved = h.app.preferences.clone(); + invalid_saved.theme_options.entry(file.id.clone()).or_default().insert("opacity".into(), json!(20)); + assert_eq!(resolved_theme(&invalid_saved, file), full, "saved preference fallback stays at the theme default"); + assert_eq!(invalid_saved.theme_options[&file.id]["opacity"], 20, "fallback must retain the raw saved value"); + super::reveal_preference(&mut h, "dialog:slider:Opacity"); + let slider = crate::scenario::rect_of("dialog:slider_track:Opacity").unwrap(); + h.step(&json!({"click":[slider.center().x,slider.center().y]})).unwrap(); + h.frames(2); + let id = format!("options-{base}-1"); + let value = h.app.preferences.theme_options[&id]["opacity"].as_f64().unwrap(); + assert!((40.0..100.0).contains(&value)); + assert!(Tokens::get().panel.a() < full.panel.a()); + assert_eq!(Tokens::get().text, full.text); + assert!(h.app.prefs_window.open, "editing must keep the same Preferences window"); + h.step(&json!({"click":{"handle":"prefs:theme_options_reset"}})).unwrap(); + h.frames(2); + assert_eq!(Tokens::get(), full); + h.call("ui.prefs", json!({"theme_options":{(id.clone()):{"opacity":60}}})); + h.frames(2); + assert!(Tokens::get().panel.a() < full.panel.a()); + h.call("ui.prefs", json!({"button":"cancel"})); + h.frames(2); + assert_eq!(Tokens::get(), full); + assert!(!h.app.preferences.theme_options.contains_key(&id)); + } + } + + #[test] + fn theme_options_provider_registry_filters_unknown_and_retains_unavailable() { + use crate::theme_options::{Availability, Provider}; + let file = crate::theme_files::from_value(&json!({"format":"solidcraft-theme/1","name":"Providers","base":"dark","options":[ + {"id":"blur","type":"range","default":0}, + {"id":"tab_colours","type":"bool","default":true}, + {"id":"tab_fade_ms","type":"range","default":100}, + {"id":"dependent","type":"bool","default":true,"parent":"tab_colours"}, + {"id":"custom","type":"bool","default":false,"label":{"en":"Custom","de":"Eigene"}}, + {"id":"opacity","type":"range","default":100} + ]})) + .unwrap_err(); + assert!(file.contains("more than one child")); + let file = crate::theme_files::from_value(&json!({"format":"solidcraft-theme/1","name":"Providers","base":"dark","options":[ + {"id":"blur","type":"range","default":0}, {"id":"tab_colours","type":"bool","default":true}, + {"id":"tab_fade_ms","type":"range","default":100}, + {"id":"custom","type":"bool","default":false,"label":{"en":"Custom","de":"Eigene"}}, + {"id":"opacity","type":"range","default":100} + ]})) + .unwrap(); + let rows = theme_option_rows(&file, "de", crate::theme_options::PROVIDERS); + assert_eq!(rows.iter().map(|r| r.definition.id.as_str()).collect::>(), ["opacity", "tab_colours", "tab_fade_ms", "custom"]); + assert_eq!(rows[0].label, "Deckkraft"); + assert_eq!(rows[3].label, "Eigene"); + assert_eq!(theme_option_rows(&file, "ja", crate::theme_options::PROVIDERS)[3].label, "Custom", "English fallback"); + let providers = [Provider { + id: "blur", + label: "prefs.theme_option_opacity", + help: "prefs.theme_option_opacity_help", + availability: || Availability::Unavailable("prefs.theme_options_unavailable"), + apply: |_, _| {}, + }]; + let rows = theme_option_rows(&file, "en", &providers); + assert_eq!(rows.iter().map(|r| r.definition.id.as_str()).collect::>(), ["blur", "custom"]); + assert_eq!(rows[0].reason.as_deref(), Some("Unavailable on this device")); + // The internal fixture goes through the real kit. No fake provider is shipped. + for dark in [false, true] { + crate::theme::set_custom(None); + crate::theme::set_dark(dark); + let mut values = crate::theme_files::effective_values(&file, &BTreeMap::new()).0; + let before = values.clone(); + let ctx = crate::fonts::test_context(); + let mut draw = |events| { + crate::scenario::clear_handles(); + crate::test_support::frame( + &ctx, + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))), + events, + focused: true, + ..Default::default() + }, + |ui| { + ui_kit::scope(ui, |ui| { + ui_kit::theme_options_list(ui, &file.id, &file.name, &rows, &mut values); + }); + }, + ); + }; + draw(vec![]); + assert!(crate::scenario::rect_of("prefs:theme_option_reason:blur").is_some()); + let at = crate::scenario::handle_at("dialog:slider:Opacity").unwrap(); + draw(crate::test_support::pointer(at, true)); + draw(crate::test_support::pointer(at, false)); + assert_eq!(values, before, "unavailable controls stay visible and inert"); + } + } + + fn option_theme_file(name: &str) -> serde_json::Value { + json!({ + "format": "solidcraft-theme/1", "name": name, "base": "dark", "colors": {"panel": "#112233"}, + "options": [ + {"id":"enabled", "type":"bool", "default":false, "label":{"en":"Enabled", "de":"Aktiv"}, "help":{"en":"Use the overlay", "de":"Farben verwenden"}}, + {"id":"amount", "type":"range", "default":5, "min":0, "max":10, "step":1, "unit":"px", "advanced":true, "section":"Details"}, + {"id":"style", "type":"choice", "default":"warm", "parent":"enabled", "values":[{"id":"warm", "label":{"en":"Warm", "de":"Warmton"}}, {"id":"cool", "label":{"en":"Cool"}}]}, + {"id":"blur", "type":"range", "default":0} + ], + "overlays": [{"when":{"opt":"enabled", "eq":true}, "colors":{"panel":"#abcdef"}}] + }) + } + + #[test] + fn theme_options_raw_values_default_while_theme_api_clamps() { + use crate::theme_files::{OptionNotice, OptionNoticeKind, OptionValue}; + use solidcraft_engine::schema::ui_prefs::ThemeOptionNoticeKind; + let file = crate::theme_files::from_value(&option_theme_file("Options A")).unwrap(); + for (raw, clamped) in [(-1.0, 0.0), (20.0, 10.0)] { + let (values, notices) = crate::theme_files::effective_values(&file, &BTreeMap::from([("amount".into(), OptionValue::Num(raw))])); + assert_eq!(values["amount"], OptionValue::Num(clamped)); + assert_eq!(notices, [OptionNotice { id: "amount".into(), kind: OptionNoticeKind::Clamped }]); + + let mut prefs = Prefs::default(); + prefs.theme_options.insert(file.id.clone(), BTreeMap::from([("amount".into(), json!(raw))])); + let saved = prefs.theme_options.clone(); + let (values, notices) = effective_theme_options(&prefs, &file); + assert_eq!(values["amount"], OptionValue::Num(5.0)); + assert_eq!(notices.len(), 1); + assert_eq!((notices[0].id.as_str(), notices[0].kind), ("amount", ThemeOptionNoticeKind::OutOfRange)); + assert_eq!(prefs.theme_options, saved); + } + } + + #[test] + fn tabs_preferences_validate_atomically_restore_safely_and_preserve_export_defaults() { + for base in ["dark", "light"] { + let file = crate::theme_files::tab_fixture(base); + let source = serde_json::to_value(&file).unwrap(); + let mut h = crate::scenario::Harness::new(); + for invalid in [json!(false), json!({"sketch":{}}), json!({"SKETCH":{"error":"#ffffff"}}), json!({"SKETCH":{"panel":"bad"}})] { + let mut damaged = source.clone(); + damaged["tabs"] = invalid; + let before = h.app.preferences.clone(); + let error = control(&mut h.app, &json!({"custom_themes":[damaged],"theme":file.id,"ui_scale":1.5,"open":"Interface"})).unwrap_err(); + assert!(error.contains("tabs"), "{error}"); + assert_eq!(h.app.preferences, before); + assert!(!h.app.prefs_window.open); + h.app.load_prefs(&json!({"preferences":{"custom_themes":[damaged,source],"theme":file.id,"zoom_reverse":true}}).to_string()); + assert_eq!(h.app.preferences.custom_themes.len(), 2); + assert!(h.app.preferences.custom_themes[0].tabs.is_empty()); + assert_eq!(h.app.preferences.custom_themes[1].tabs, file.tabs); + assert!(h.app.preferences.zoom_reverse); + } + let out = control(&mut h.app, &json!({"custom_themes":[source],"theme":file.id,"theme_options":{&file.id:{"tab_colours":false,"tab_fade_ms":123}},"theme_export":true})).unwrap(); + assert_eq!(out["theme_file"]["tabs"], serde_json::to_value(&file.tabs).unwrap()); + assert_eq!(out["theme_file"]["options"][0]["default"], true); + assert_eq!(out["theme_file"]["options"][1]["default"], 240.0); + assert_eq!(out["theme_option_values"]["tab_fade_ms"], 123.0, "step is a control increment, never rounding"); + assert_eq!(out["theme_option_defs"].as_array().unwrap().iter().find(|o| o["id"] == "tab_fade_ms").unwrap()["step"], 20.0); + let mut restored = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); + restored.load_prefs(&h.app.prefs()); + assert_eq!(restored.preferences.custom_themes, h.app.preferences.custom_themes); + assert_eq!(restored.preferences.theme_options, h.app.preferences.theme_options); + } + } + + #[test] + fn tabs_followed_reload_updates_surfaces_without_losing_saved_options() { + let mut h = crate::scenario::Harness::new(); + let mut source = crate::theme_files::export_file(&crate::theme_files::tab_fixture("dark")); + let path = h.dir.join("tabs.json"); + std::fs::write(&path, source.to_string()).unwrap(); + control(&mut h.app, &json!({"theme_follow":path,"theme_options":{"file-tabs-dark":{"tab_fade_ms":0}}})).unwrap(); + assert!(h.call("engine.execute", json!({"command":"sketch.create","params":{"plane":"XY"}}))["ok"].as_bool().unwrap()); + h.frames(2); + assert_eq!(Tokens::get().viewport_top, egui::Color32::from_rgb(0x10, 0x31, 0x28)); + source["tabs"]["SKETCH"]["viewport_top"] = json!("#1a4232"); + std::fs::write(&path, source.to_string()).unwrap(); + control(&mut h.app, &json!({"theme_follow":path})).unwrap(); + h.frames(2); + assert_eq!(Tokens::get().viewport_top, egui::Color32::from_rgb(0x1a, 0x42, 0x32)); + assert_eq!(h.app.preferences.theme_options["file-tabs-dark"]["tab_fade_ms"], 0); + source["tabs"]["SKETCH"]["warning"] = json!("#ffffff"); + std::fs::write(&path, source.to_string()).unwrap(); + assert!(control(&mut h.app, &json!({"theme_follow":path})).unwrap()["theme_follow_error"].as_str().unwrap().contains("warning")); + h.frames(2); + assert_eq!(Tokens::get().viewport_top, egui::Color32::from_rgb(0x1a, 0x42, 0x32)); + control(&mut h.app, &json!({"theme_follow":""})).unwrap(); + h.frames(2); + assert_eq!(Tokens::get(), Tokens::dark()); + } + + #[test] + fn theme_options_direct_definitions_validate_and_restore_safely() { + let mut source = option_theme_file("Options A"); + source["id"] = json!("options-a"); + let mut other = source.clone(); + other["id"] = json!("options-b"); + other["name"] = json!("Options B"); + let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); + let invalid_tables = [ + ("options", json!((0..17).map(|i| json!({"id":format!("option_{i}"),"type":"bool","default":true})).collect::>())), + ("options", json!([{ "id":"amount","type":"range","default":5,"min":10,"max":0,"step":1 }])), + ("options", json!([{ "id":"amount","type":"range","default":11,"min":0,"max":10,"step":1 }])), + ("options", json!([{ "id":"amount","type":"range","default":5,"min":0,"max":10,"step":0 }])), + ("options", json!([{ "id":"amount","type":"range","default":"bad","min":0,"max":10,"step":1 }])), + ("options", json!([{ "id":"enabled","type":"unknown","default":true }])), + ("options", json!([{ "id":"enabled","type":"bool","default":true,"parent":"missing" }])), + ("options", json!([{ "id":"a","type":"bool","default":true,"parent":"b" },{ "id":"b","type":"bool","default":true,"parent":"a" }])), + ("options", json!([{ "id":"style","type":"choice","default":"missing","values":[{"id":"a","label":{}}] }])), + ("overlays", json!((0..33).map(|_| json!({"when":{"opt":"enabled","eq":true},"colors":{"panel":"#abcdef"}})).collect::>())), + ("overlays", json!([{ "when":{"opt":"enabled","eq":1},"colors":{"panel":"#abcdef"} }])), + ("overlays", json!([{ "when":{"opt":"unknown","eq":true},"colors":{"panel":"#abcdef"} }])), + ("overlays", json!([{ "when":{"opt":"enabled","eq":true},"radii":{"radius_sm":-1} }])), + ]; + for (table, invalid) in invalid_tables { + let mut malformed = source.clone(); + malformed[table] = invalid; + assert!(crate::theme_files::from_value(&malformed).is_err(), "{malformed}"); + let before = app.preferences.clone(); + let error = control(&mut app, &json!({"custom_themes":[malformed],"ui_scale":1.5,"open":"Interface"})).unwrap_err(); + assert!(error.contains("custom_themes[0]"), "{error}"); + assert_eq!(app.preferences, before); + assert!(!app.prefs_window.open); + + let mut restored = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); + restored.load_prefs( + &json!({"preferences":{ + "custom_themes":[malformed,other],"theme":"options-a","zoom_reverse":true, + "theme_options":{"options-a":{"amount":99}} + }}) + .to_string(), + ); + assert_eq!(restored.preferences.custom_themes.len(), 2); + assert!(restored.preferences.custom_themes[0].options.is_empty()); + assert!(restored.preferences.custom_themes[0].overlays.is_empty()); + assert_eq!(restored.preferences.custom_themes[0].colors, restored.preferences.custom_themes[1].colors); + assert!(!restored.preferences.custom_themes[1].options.is_empty()); + assert!(restored.preferences.zoom_reverse); + apply(&mut restored, &egui::Context::default()); + assert_eq!( + resolved_theme(&restored.preferences, &restored.preferences.custom_themes[0]).panel, + egui::Color32::from_rgb(0x11, 0x22, 0x33) + ); + let out = control(&mut restored, &json!({"ui_scale":1.25})).unwrap(); + assert_eq!(out["theme_option_defs"], json!([])); + assert_eq!(out["theme_option_values"], json!({})); + assert_eq!(out["theme_options"]["options-a"]["amount"], 99); + assert!(restored.preferences.zoom_reverse); + } + + // Reserved fields ignored by import must also be ignored before serde sees an update. + source["options"][3] = json!({"id":"blur","type":"range","default":999,"step":null,"unit":false,"label":null,"advanced":"ignored"}); + let expected = crate::theme_files::from_value(&source).unwrap(); + control(&mut app, &json!({"custom_themes":[source],"theme":"options-a"})).unwrap(); + assert_eq!(app.preferences.custom_themes, [expected]); + app.preferences.custom_themes[0].options[1].kind = + crate::theme_files::OptionKind::Range { default: 5.0, min: 10.0, max: 0.0, step: 1.0, unit: None }; + let out = control(&mut app, &json!({"ui_scale":1.25})).unwrap(); + assert_eq!(out["theme_option_defs"], json!([])); + assert_eq!(app.preferences.custom_themes[0].colors["panel"], "#112233"); + } + + #[test] + fn theme_options_persist_isolate_reset_remove_and_export_definitions() { + let mut h = crate::scenario::Harness::new(); + let out = + control(&mut h.app, &json!({"theme_import":option_theme_file("Options A"), "theme_options":{"options-a":{"enabled":true,"amount":7.5}}})) + .unwrap(); + assert_eq!(out["theme_option_values"]["enabled"], true); + h.frames(2); + assert_eq!(Tokens::get().panel, egui::Color32::from_rgb(0xab, 0xcd, 0xef)); + control(&mut h.app, &json!({"theme_import":option_theme_file("Options B"), "theme_options":{"options-b":{"amount":3}}})).unwrap(); + assert_eq!(control(&mut h.app, &json!({})).unwrap()["theme_option_values"]["enabled"], false); + let out = control(&mut h.app, &json!({"theme":"options-a", "theme_export":true})).unwrap(); + assert_eq!(out["theme_option_values"]["amount"], 7.5); + assert_eq!(out["theme_file"]["options"][0]["default"], false); + assert_eq!(out["theme_file"]["colors"]["panel"], "#112233"); + assert_eq!(out["theme_file"]["overlays"][0]["colors"]["panel"], "#abcdef"); + let mut other = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); + other.load_prefs(&h.app.prefs()); + assert_eq!(other.preferences.theme_options, h.app.preferences.theme_options); + control(&mut other, &json!({"theme_options":{"options-a":{"amount":null}}})).unwrap(); + assert_eq!(control(&mut other, &json!({})).unwrap()["theme_option_values"]["amount"], 5.0); + assert!(other.preferences.theme_options["options-a"].contains_key("enabled")); + control(&mut other, &json!({"theme_options":{"options-a":{}}})).unwrap(); + assert!(!other.preferences.theme_options.contains_key("options-a")); + assert!(other.preferences.theme_options.contains_key("options-b")); + control(&mut h.app, &json!({"theme_remove":"options-a"})).unwrap(); + assert!(!h.app.preferences.theme_options.contains_key("options-a")); + assert!(h.app.preferences.theme_options.contains_key("options-b")); + control(&mut other, &json!({"open":"Interface", "button":"defaults"})).unwrap(); + assert!(other.preferences.theme_options.is_empty()); + h.frames(2); + assert_eq!(Tokens::get().panel, Tokens::dark().panel); + control( + &mut h.app, + &json!({"theme_import":option_theme_file("Options A"),"theme_options":{"options-a":{"enabled":true}},"open":"Interface"}), + ) + .unwrap(); + control(&mut h.app, &json!({"button":"apply"})).unwrap(); + control(&mut h.app, &json!({"button":"defaults"})).unwrap(); + h.frames(2); + assert!(h.app.preferences.theme_options.is_empty()); + assert_eq!(Tokens::get().panel, Tokens::dark().panel); + control(&mut h.app, &json!({"button":"cancel"})).unwrap(); + h.frames(2); + assert_eq!(Tokens::get().panel, egui::Color32::from_rgb(0xab, 0xcd, 0xef)); + } + + #[test] + fn theme_options_saved_invalid_values_survive_with_defaults_and_notices() { + let mut h = crate::scenario::Harness::new(); + control(&mut h.app, &json!({"theme_import":option_theme_file("Options A")})).unwrap(); + let mut saved = serde_json::to_value(&h.app.preferences).unwrap(); + saved["theme_options"] = + json!({"options-a":{"enabled":[], "amount":99, "style":"dropped", "removed":{"future":true}}, "unknown-theme":{"anything":null}}); + saved["zoom_reverse"] = json!(true); + saved["ui_scale"] = json!("bad saved field"); + h.app.preferences = lenient(&saved); + assert!(h.app.preferences.zoom_reverse); + let out = control(&mut h.app, &json!({"look_at_anim":false})).unwrap(); + assert_eq!(out["theme_options"], saved["theme_options"]); + assert_eq!(out["theme_option_values"], json!({"enabled":false,"amount":5.0,"style":"warm","blur":0.0})); + assert_eq!( + out["theme_option_notices"], + json!([ + {"id":"amount","kind":"out_of_range"}, {"id":"enabled","kind":"wrong_type"}, + {"id":"removed","kind":"unknown_option"}, {"id":"style","kind":"unknown_choice"} + ]) + ); + let mut other = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); + other.load_prefs(&h.app.prefs()); + assert_eq!(other.preferences.theme_options, h.app.preferences.theme_options); + assert!(other.preferences.zoom_reverse); + } + + #[test] + fn theme_options_control_refuses_entire_mixed_call_before_side_effects() { + let mut h = crate::scenario::Harness::new(); + control(&mut h.app, &json!({"theme_import":option_theme_file("Options A")})).unwrap(); + h.app.stand_up = Some(vec!["Offered".into()]); + h.app.stand_up_note = true; + let before = h.app.prefs(); + for (options, key) in [ + (json!({"unknown":{"enabled":true}}), "theme_options.unknown"), + (json!({"options-a":{"unknown":true}}), "theme_options.options-a.unknown"), + (json!({"options-a":{"enabled":1}}), "theme_options.options-a.enabled"), + (json!({"options-a":{"amount":10.0000001}}), "theme_options.options-a.amount"), + (json!({"options-a":{"amount":-1}}), "theme_options.options-a.amount"), + (json!({"options-a":{"style":"missing"}}), "theme_options.options-a.style"), + (json!({"options-a":{"amount":8,"enabled":{}},"options-b":{"enabled":true}}), "theme_options.options-a.enabled"), + ] { + let err = control( + &mut h.app, + &json!({ + "theme_import":option_theme_file("Options B"), "theme_options":options, + "theme":"options-b", "theme_follow":"", "open":"Interface", "button":"defaults", "stand_up":false, + "msaa":4, "default_units":"inch", "up_axis":"y", "ui_scale":1.5 + }), + ) + .unwrap_err(); + assert!(err.contains(key), "{err}"); + assert_eq!(h.app.prefs(), before, "{key}"); + assert!(!h.app.prefs_window.open && h.app.prefs_window.snapshot.is_none()); + assert_eq!(h.app.stand_up.as_deref(), Some(["Offered".to_string()].as_slice())); + assert!(h.app.stand_up_note); + } + let err = control(&mut h.app, &json!({"theme_remove":"options-a", "theme_options":{"options-a":{}}})).unwrap_err(); + assert!(err.contains("theme_options.options-a")); + assert_eq!(h.app.prefs(), before); + assert!(control(&mut h.app, &json!({"theme_options":{"options-a":{"enabled":true}},"ui_scale":9})).is_err()); + assert_eq!(h.app.prefs(), before); + assert!(control(&mut h.app, &json!({"theme_import":option_theme_file("Options B"),"button":"invalid"})).is_err()); + assert_eq!(h.app.prefs(), before); + assert!(control(&mut h.app, &json!({"theme_options":null})).is_err()); + } + + #[test] + fn theme_options_active_reply_is_localized_ordered_and_matches_schema_types() { + use solidcraft_engine::schema::ui_prefs::{PrefsReply, ThemeOptionKind}; + let mut h = crate::scenario::Harness::new(); + let out = control(&mut h.app, &json!({"theme_import":option_theme_file("Options A"), "language":"de"})).unwrap(); + let reply: PrefsReply = serde_json::from_value(out.clone()).unwrap(); + assert_eq!(reply.options.theme_option_defs.iter().map(|o| o.id.as_str()).collect::>(), ["blur", "enabled", "amount", "style"]); + let defs = &reply.options.theme_option_defs; + assert!(defs[0].standard && !defs[0].available); + assert_eq!(defs[0].reason.as_deref(), Some("not implemented")); + assert_eq!((&defs[1].label, &defs[1].help), (&"Aktiv".to_string(), &"Farben verwenden".to_string())); + assert!(defs[1].available && defs[1].reason.is_none()); + assert!(defs[2].advanced && defs[2].section.as_deref() == Some("Details")); + let ThemeOptionKind::Choice { values, .. } = &defs[3].kind else { panic!("choice definition") }; + assert_eq!(values.iter().map(|v| v.label.as_str()).collect::>(), ["Warmton", "Cool"]); + assert_eq!(serde_json::to_value(reply).unwrap(), out); + let out = control(&mut h.app, &json!({"theme":"dark"})).unwrap(); + assert_eq!(out["theme_option_defs"], json!([])); + assert_eq!(out["theme_option_values"], json!({})); + assert_eq!(out["theme_option_notices"], json!([])); + } + + #[test] + fn theme_options_followed_file_uses_its_id_and_reload_keeps_saved_entries() { + let mut h = crate::scenario::Harness::new(); + let path = h.dir.join("follow-options.json"); + let mut file = option_theme_file("Follow Options"); + file["base"] = json!("light"); + std::fs::write(&path, file.to_string()).unwrap(); + let out = control(&mut h.app, &json!({"theme_follow":path, "theme_options":{"file-follow-options":{"enabled":true,"amount":8}}})).unwrap(); + assert_eq!(out["theme"], "dark"); + assert_eq!(out["theme_option_values"]["enabled"], true); + h.frames(2); + assert_eq!(Tokens::get().panel, egui::Color32::from_rgb(0xab, 0xcd, 0xef)); + assert!(!h.app.ui.dark); + let saved = h.app.prefs(); + let err = control(&mut h.app, &json!({"theme":"light", "theme_options":{"missing":{}}, "stand_up":false})).unwrap_err(); + assert!(err.contains("theme_options.missing")); + assert_eq!(h.app.prefs(), saved); + assert_eq!(active_theme_file(&h.app).unwrap().id, "file-follow-options"); + let other_path = h.dir.join("other-options.json"); + std::fs::write(&other_path, option_theme_file("Other Options").to_string()).unwrap(); + let err = control(&mut h.app, &json!({"theme_follow":other_path,"theme_options":{"file-other-options":{"enabled":42}}})).unwrap_err(); + assert!(err.contains("theme_options.file-other-options.enabled"), "{err}"); + assert_eq!(h.app.prefs(), saved); + assert_eq!(active_theme_file(&h.app).unwrap().id, "file-follow-options"); + file["options"][1]["max"] = json!(6); + file["options"][1]["default"] = json!(2); + std::fs::write(&path, file.to_string()).unwrap(); + let out = control(&mut h.app, &json!({"theme_follow":path, "theme_export":true})).unwrap(); + assert_eq!(out["theme_option_values"]["amount"], 2.0); + assert_eq!(out["theme_options"]["file-follow-options"]["amount"], 8); + assert_eq!(out["theme_option_notices"], json!([{"id":"amount","kind":"out_of_range"}])); + assert_eq!(out["theme_file"]["name"], "Follow Options"); + assert_eq!(out["theme_file"]["options"][1]["default"], 2.0); + std::fs::write(&path, "incomplete").unwrap(); + let out = control(&mut h.app, &json!({"theme_follow":path})).unwrap(); + assert!(out["theme_follow_error"].is_string()); + assert_eq!(out["theme_option_values"]["enabled"], true); + let out = control(&mut h.app, &json!({"theme_follow":""})).unwrap(); + assert_eq!(out["dark"], true); + assert!(active_theme_file(&h.app).is_none()); + h.frames(2); + assert_eq!(Tokens::get().panel, Tokens::dark().panel); + let out = control(&mut h.app, &json!({"theme_follow":path})).unwrap(); + assert_eq!(out["theme_option_values"], json!({}), "stopping clears the last good file"); + control(&mut h.app, &json!({"theme_follow":""})).unwrap(); + std::fs::write(&path, file.to_string()).unwrap(); + follow_theme_file_for_run(&mut h.app, path.to_str().unwrap()).unwrap(); + h.frames(2); + let out = control(&mut h.app, &json!({})).unwrap(); + assert_eq!(out["theme_follow"], ""); + assert_eq!(out["theme_option_values"]["enabled"], true); + assert_eq!(Tokens::get().panel, egui::Color32::from_rgb(0xab, 0xcd, 0xef)); + assert!(!h.app.ui.dark); + let saved = h.app.prefs(); + let launch = h.app.prefs_window.follow.launch.clone(); + assert!(control(&mut h.app, &json!({"theme":"dark","theme_options":{"missing":{}}})).is_err()); + assert_eq!(h.app.prefs(), saved); + assert_eq!(h.app.prefs_window.follow.launch, launch); + assert_eq!(active_theme_file(&h.app).unwrap().id, "file-follow-options"); + let out = control(&mut h.app, &json!({"theme":"dark"})).unwrap(); + assert_eq!(out["dark"], true); + assert!(out["theme_followed"].is_null()); + assert!(h.app.prefs_window.follow.theme().is_none()); + h.frames(2); + assert_eq!(Tokens::get().panel, Tokens::dark().panel); + } + + #[test] + fn gruvbox_legacy_selection_migrates_on_load_and_preserves_saved_values() { + for (old, family, contrast) in crate::theme_files::GRUVBOX_ALIASES { + let saved = json!({"theme":old,"theme_options":{ + family:{"contrast":"standard","unrelated":{"keep":true}}, + old:{"contrast":"standard","future":42} + }}); + let migrated = lenient(&saved); + assert_eq!(migrated.theme, family); + assert_eq!(migrated.theme_options[family]["contrast"], contrast); + assert_eq!(migrated.theme_options[family]["unrelated"], json!({"keep":true})); + assert_eq!(migrated.theme_options[family]["future"], 42); + assert!(!migrated.theme_options.contains_key(old)); + assert_eq!(serde_json::to_value(migrate(migrated.clone())).unwrap(), serde_json::to_value(&migrated).unwrap()); + let mut h = crate::scenario::Harness::new(); + h.app.load_prefs(&json!({"preferences":saved}).to_string()); + assert_eq!(h.app.preferences.theme, family); + assert_eq!(h.app.preferences.theme_options[family]["contrast"], contrast); + let output: serde_json::Value = serde_json::from_str(&h.app.prefs()).unwrap(); + assert_eq!(output["preferences"]["theme"], family); + assert_eq!(output["preferences"]["theme_options"][family]["contrast"], contrast); + } + // An unselected legacy map must not replace an existing canonical contrast. + let migrated = lenient(&json!({"theme":"dark","theme_options":{ + "gruvbox-dark":{"contrast":"soft"},"gruvbox-dark-hard":{"contrast":"hard"} + }})); + assert_eq!(migrated.theme_options["gruvbox-dark"]["contrast"], "soft"); + } + + #[test] + fn gruvbox_control_legacy_ids_allow_explicit_choices_resets_and_atomic_errors() { + let mut h = crate::scenario::Harness::new(); + for (old, family, contrast) in crate::theme_files::GRUVBOX_ALIASES { + let out = control(&mut h.app, &json!({"theme":old})).unwrap(); + assert_eq!(out["theme"], family); + assert_eq!(out["theme_option_values"]["contrast"], contrast); + let ids = out["theme_ids"].as_array().unwrap(); + assert_eq!(ids.iter().filter(|id| id.as_str().is_some_and(|id| id.starts_with("gruvbox-"))).count(), 2); + let out = control(&mut h.app, &json!({"theme":old,"theme_options":{family:{"contrast":"standard"}}})).unwrap(); + assert_eq!(out["theme_option_values"]["contrast"], "standard"); + let out = control(&mut h.app, &json!({"theme":old,"theme_options":{family:{"contrast":null}}})).unwrap(); + assert_eq!(out["theme_option_values"]["contrast"], "standard"); + let out = control(&mut h.app, &json!({"theme":old,"theme_options":{family:{}}})).unwrap(); + assert_eq!(out["theme_option_values"]["contrast"], "standard"); + let out = control(&mut h.app, &json!({"theme":old,"theme_options":{old:{"contrast":"hard"},family:{"contrast":"soft"}}})).unwrap(); + assert_eq!(out["theme_option_values"]["contrast"], "soft", "canonical explicit values win over alias entries"); + let before = h.app.prefs(); + assert!(control(&mut h.app, &json!({"theme":old,"open":"Interface","theme_options":{family:{"contrast":"invalid"}}})).is_err()); + assert_eq!(h.app.prefs(), before, "failed changes do not migrate or open the dialog"); + assert!(!h.app.prefs_window.open); + } + } + #[test] fn theme_files_import_apply_export_save_and_remove() { use crate::theme::{ColorVision, Tokens}; use serde_json::json; let mut h = crate::scenario::Harness::new(); let out = h.call("ui.prefs", json!({"theme": "gruvbox-dark-hard"})); - assert_eq!(out["result"]["theme"], "gruvbox-dark-hard", "{out}"); + assert_eq!(out["result"]["theme"], "gruvbox-dark", "{out}"); + assert_eq!(out["result"]["theme_option_values"]["contrast"], "hard"); assert!(out["result"]["theme_ids"].as_array().is_some_and(|ids| ids.len() == 3 + crate::theme_files::builtin().len()), "{out}"); h.frames(2); - let hard = crate::theme_files::find("gruvbox-dark-hard", &[]).map(crate::theme_files::resolve).unwrap(); + let hard = resolved_theme(&h.app.preferences, active_theme_file(&h.app).unwrap()); assert!(h.app.ui.dark); assert_eq!(Tokens::get().panel, hard.panel); assert_eq!( @@ -6031,6 +7540,79 @@ mod tests { std::fs::remove_dir_all(dir).unwrap(); } + /// The settings `SolveApp::prefs` would write right now, parsed. + fn saved_prefs(app: &SolveApp) -> serde_json::Value { + serde_json::from_str(&app.prefs()).unwrap() + } + + #[test] + fn unapplied_changes_are_not_saved_while_the_dialog_is_open() { + let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); + assert_eq!(saved_prefs(&app)["preferences"]["theme"], "dark"); + assert_eq!(saved_prefs(&app)["preferences"]["sketch_tab_always"], false); + // Open the dialog: the snapshot is taken as `show` and the control channel take it. + app.prefs_window.open = true; + app.prefs_window.snapshot = Some(Snapshot::take(&app)); + // The Light tile and the Sketch tab box, clicked without Apply. + set(&mut app, &json!({"theme": "light", "sketch_tab_always": true})).unwrap(); + assert_eq!(app.preferences.theme, "light", "the live app has the change"); + assert!(!app.ui.dark); + let held = saved_prefs(&app); + assert_eq!(held["preferences"]["theme"], "dark", "a change that was not applied must not be saved"); + assert_eq!(held["preferences"]["sketch_tab_always"], false); + assert_eq!(held["dark"], true, "the Light tile's dark flag is not saved either"); + // Apply makes the change the baseline: from now on it is saved. + app.prefs_window.snapshot = Some(Snapshot::take(&app)); + let applied = saved_prefs(&app); + assert_eq!(applied["preferences"]["theme"], "light"); + assert_eq!(applied["dark"], false); + // A second unapplied change is held back too, and Cancel puts it back. + set(&mut app, &json!({"theme": "dark"})).unwrap(); + assert_eq!(saved_prefs(&app)["preferences"]["theme"], "light", "the second change waits for Apply too"); + close(&mut app, true); + let after = saved_prefs(&app); + assert_eq!(after["preferences"]["theme"], "light", "Cancel restores the applied state"); + assert_eq!(after["dark"], false); + assert_eq!(after["preferences"]["sketch_tab_always"], true, "Apply's change stays"); + } + + #[test] + fn settings_the_dialog_cannot_change_keep_saving_while_it_is_open() { + let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); + app.prefs_window.open = true; + app.prefs_window.snapshot = Some(Snapshot::take(&app)); + // A recent file joins while the dialog is open: not a dialog setting, so it is saved. + app.home.recent.insert(0, "bracket.solid".to_string()); + assert_eq!(saved_prefs(&app)["recent"][0], "bracket.solid"); + } + + #[test] + fn shortcut_changes_are_not_saved_while_the_dialog_is_open() { + let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); + app.keymap.custom.insert("file.new".into(), vec!["Ctrl+N".into()]); + app.prefs_window.open = true; + app.prefs_window.snapshot = Some(Snapshot::take(&app)); + app.keymap.custom.insert("file.save".into(), vec!["Ctrl+S".into()]); + let saved = saved_prefs(&app); + assert_eq!(saved["shortcuts"]["file.new"], "Ctrl+N", "the shortcut from before the dialog is saved"); + assert!(saved["shortcuts"].get("file.save").is_none(), "a shortcut rebound without Apply is not saved"); + } + + #[test] + fn control_channel_holds_unapplied_changes_out_of_the_saved_settings() { + let mut h = crate::scenario::Harness::new(); + h.call("ui.prefs", json!({"open": "Layout"})); + h.call("ui.prefs", json!({"sketch_tab_always": true})); + let saved = saved_prefs(&h.app); + assert!(h.app.preferences.sketch_tab_always); + assert_eq!(saved["preferences"]["sketch_tab_always"], false, "the control channel's change waits for Apply"); + h.call("ui.prefs", json!({"button": "apply"})); + assert_eq!(saved_prefs(&h.app)["preferences"]["sketch_tab_always"], true, "Apply makes it the saved state"); + h.call("ui.prefs", json!({"sketch_tab_always": false})); + h.call("ui.prefs", json!({"button": "cancel"})); + assert_eq!(saved_prefs(&h.app)["preferences"]["sketch_tab_always"], true, "Cancel puts back what Apply left"); + } + #[test] fn startup_msaa_check_keeps_the_default_unless_fallback_is_confirmed() { let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); @@ -6160,7 +7742,7 @@ mod tests { assert_eq!(h.app.session.doc.to_json(), design); let mut restored = SolveApp::new(solidcraft_engine::Session::default(), Default::default()); restored.load_prefs(&h.app.prefs()); - assert_eq!(restored.preferences.navigator_style, NavigatorStyle::Cube); + assert_eq!(restored.preferences.navigator_style, NavigatorStyle::Axes, "a change that was not applied is not saved (#9730)"); let before = h.app.preferences.clone(); assert!(control(&mut h.app, &json!({"navigator_style":"unknown", "nav":"solidworks"})).is_err()); assert_eq!(h.app.preferences, before, "invalid settings must fail atomically"); @@ -6252,7 +7834,7 @@ mod tests { let rows = || crate::browser::drawn_rows().into_iter().map(|(label, ..)| label).collect::>(); assert!(OriginPlaneNames::PLANES.iter().all(|p| rows().iter().any(|r| r == p)), "{:?}", rows()); let design = h.app.session.doc.to_json(); - h.call("ui.prefs", json!({"open":"Interface"})); + h.call("ui.prefs", json!({"open":"Design"})); super::reveal_preference(&mut h, "prefs:origin_plane_names:top_front_right"); h.step(&json!({"click":{"handle":"prefs:origin_plane_names:top_front_right"}})).unwrap(); assert_eq!(h.app.preferences.origin_plane_names, TopFrontRight); @@ -6287,10 +7869,10 @@ mod tests { let before = h.app.preferences.clone(); assert!(control(&mut h.app, &json!({"origin_plane_names":"top", "nav":"solidworks"})).is_err()); assert_eq!(h.app.preferences, before, "an unknown naming fails atomically"); - h.call("ui.prefs", json!({"open":"Interface","origin_plane_names":"xyz"})); + h.call("ui.prefs", json!({"open":"Design","origin_plane_names":"xyz"})); h.call("ui.prefs", json!({"button":"cancel"})); assert_eq!(h.app.preferences.origin_plane_names, TopFrontRight, "Cancel takes it back"); - h.call("ui.prefs", json!({"open":"Interface"})); + h.call("ui.prefs", json!({"open":"Design"})); h.call("ui.prefs", json!({"button":"defaults"})); assert_eq!(h.app.preferences.origin_plane_names, Xyz, "Restore Defaults: XY, XZ and YZ"); h.frames(2); @@ -6324,10 +7906,14 @@ mod tests { for step in steps.iter().take(transition) { h.step(step).unwrap(); } - h.call("ui.prefs", json!({"open":"Interface"})); + h.call("ui.prefs", json!({"open":"Layout"})); + let page = crate::scenario::rect_of("prefs:page_body").unwrap(); + h.call("ui.scroll", json!({"x":page.center().x,"y":page.center().y,"delta":-10000})); + h.frames(60); let at = h.widget("Under their feature").unwrap(); let page = crate::scenario::rect_of("prefs:page_body").unwrap(); assert!(!page.contains(egui::pos2(at[0] as f32, at[1] as f32)), "the real control begins scrolled out of view"); + assert!((at[1] as f32) < page.top(), "scrolling to the bottom puts the target above the viewport"); assert_eq!(h.app.preferences.history_sketches, HistorySketches::InOrder); h.step(&json!({"widget":"Under their feature", "near":"Sketches"})).unwrap(); assert_eq!(h.app.preferences.history_sketches, HistorySketches::UnderFeature); @@ -6344,7 +7930,7 @@ mod tests { for step in steps.iter().skip(transition + 2) { h.step(step).unwrap(); } - h.call("ui.prefs", json!({"open":"Interface"})); + h.call("ui.prefs", json!({"open":"Layout"})); h.step(&json!({"widget":"Navigation"})).unwrap(); assert_eq!(h.app.prefs_window.section, PAGE_NAVIGATION, "sidebar navigation remains a real click"); h.step(&json!({"widget":"Cancel"})).unwrap(); @@ -6359,7 +7945,7 @@ mod tests { let mut h = crate::scenario::Harness::new(); h.call("ui.prefs", json!({"timeline_left": true, "timeline_under_browser": true})); let design = h.app.session.doc.to_json(); - h.call("ui.prefs", json!({"open": "Interface"})); + h.call("ui.prefs", json!({"open": "Layout"})); let row = h.app.tr("prefs.history_sketches").to_string(); let under = h.app.tr("prefs.history_sketches_under_feature").to_string(); let handle = format!("dialog:{row}:{under}"); @@ -6379,7 +7965,7 @@ mod tests { let before = h.app.preferences.clone(); assert!(control(&mut h.app, &json!({"history_sketches": "sideways"})).is_err()); assert_eq!(h.app.preferences, before, "an unknown value changes nothing"); - h.call("ui.prefs", json!({"open": "Interface"})); + h.call("ui.prefs", json!({"open": "Layout"})); h.call("ui.prefs", json!({"button": "defaults"})); assert_eq!(h.app.preferences.history_sketches, HistorySketches::InOrder); h.call("ui.prefs", json!({"button": "cancel"})); @@ -6393,7 +7979,7 @@ mod tests { assert_eq!(old.dialog_buttons, DialogButtons::Bottom, "settings from before keep the footer"); assert_eq!(lenient(&json!({"dialog_buttons": "sideways"})).dialog_buttons, DialogButtons::Bottom); let mut h = crate::scenario::Harness::new(); - h.call("ui.prefs", json!({"open": "Interface"})); + h.call("ui.prefs", json!({"open": "Layout"})); super::reveal_preference(&mut h, "prefs:dialog_buttons:top"); h.step(&json!({"click": {"handle": "prefs:dialog_buttons:top"}})).unwrap(); assert_eq!(h.app.preferences.dialog_buttons, DialogButtons::Top); @@ -6408,7 +7994,7 @@ mod tests { let before = h.app.preferences.clone(); assert!(control(&mut h.app, &json!({"dialog_buttons": "sideways"})).is_err()); assert_eq!(h.app.preferences, before, "an unknown value changes nothing"); - h.call("ui.prefs", json!({"open": "Interface"})); + h.call("ui.prefs", json!({"open": "Layout"})); h.call("ui.prefs", json!({"button": "defaults"})); assert_eq!(h.app.preferences.dialog_buttons, DialogButtons::Bottom); h.call("ui.prefs", json!({"button": "cancel"})); @@ -6425,7 +8011,7 @@ mod tests { assert!(h.app.preferences.free_orbit); let mut restored = SolveApp::new(solidcraft_engine::Session::default(), Default::default()); restored.load_prefs(&h.app.prefs()); - assert!(restored.preferences.free_orbit); + assert!(!restored.preferences.free_orbit, "a change that was not applied is not saved (#9730)"); h.call("ui.prefs", json!({"button":"cancel"})); assert!(!h.app.preferences.free_orbit); h.call("ui.prefs", json!({"open":"Navigation","free_orbit":true,"button":"apply"})); @@ -6454,7 +8040,7 @@ mod tests { assert!(h.app.preferences.radial_context_menu, "the actual checkbox enables radial menus"); let mut restored = SolveApp::new(solidcraft_engine::Session::default(), Default::default()); restored.load_prefs(&h.app.prefs()); - assert!(restored.preferences.radial_context_menu, "enabled preference survives save/load"); + assert!(!restored.preferences.radial_context_menu, "a change that was not applied is not saved (#9730)"); h.call("ui.prefs", json!({"button":"cancel"})); assert!(!h.app.preferences.radial_context_menu, "Cancel restores the default-off setting"); h.call("ui.prefs", json!({"open":"Navigation","radial_context_menu":true})); @@ -6507,7 +8093,7 @@ mod tests { let mut h = crate::scenario::Harness::new(); let design = h.app.session.doc.to_json(); let revision = h.app.session.revision; - h.call("ui.prefs", json!({"open":"Interface"})); + h.call("ui.prefs", json!({"open":"Layout"})); super::reveal_preference(&mut h, "prefs:persistent_ribbon_pressed"); let checkbox = crate::scenario::handle_at("prefs:persistent_ribbon_pressed").unwrap(); h.call("ui.click", json!({"x":checkbox.x,"y":checkbox.y})); @@ -6515,10 +8101,10 @@ mod tests { assert_eq!(h.call("ui.prefs", json!({}))["result"]["persistent_ribbon_pressed"], false); let mut restored = SolveApp::new(Default::default(), Default::default()); restored.load_prefs(&h.app.prefs()); - assert!(!restored.preferences.persistent_ribbon_pressed); + assert!(restored.preferences.persistent_ribbon_pressed, "a change that was not applied is not saved (#9730)"); h.call("ui.prefs", json!({"button":"cancel"})); assert!(h.app.preferences.persistent_ribbon_pressed); - h.call("ui.prefs", json!({"open":"Interface","persistent_ribbon_pressed":false,"button":"apply"})); + h.call("ui.prefs", json!({"open":"Layout","persistent_ribbon_pressed":false,"button":"apply"})); h.call("ui.prefs", json!({"button":"defaults"})); assert!(h.app.preferences.persistent_ribbon_pressed); h.call("ui.prefs", json!({"button":"cancel"})); @@ -6539,7 +8125,7 @@ mod tests { let mut h = crate::scenario::Harness::new(); let design = h.app.session.doc.to_json(); let revision = h.app.session.revision; - h.call("ui.prefs", json!({"open":"Interface"})); + h.call("ui.prefs", json!({"open":"Graphics"})); super::reveal_preference(&mut h, "prefs:viewport_analysis_status"); let checkbox = crate::scenario::handle_at("prefs:viewport_analysis_status").unwrap(); h.call("ui.click", json!({"x":checkbox.x,"y":checkbox.y})); @@ -6547,10 +8133,10 @@ mod tests { assert_eq!(h.call("ui.prefs", json!({}))["result"]["viewport_analysis_status"], false); let mut restored = SolveApp::new(Default::default(), Default::default()); restored.load_prefs(&h.app.prefs()); - assert!(!restored.preferences.viewport_analysis_status); + assert!(restored.preferences.viewport_analysis_status, "a change that was not applied is not saved (#9730)"); h.call("ui.prefs", json!({"button":"cancel"})); assert!(h.app.preferences.viewport_analysis_status); - h.call("ui.prefs", json!({"open":"Interface","viewport_analysis_status":false,"button":"apply"})); + h.call("ui.prefs", json!({"open":"Graphics","viewport_analysis_status":false,"button":"apply"})); h.call("ui.prefs", json!({"button":"defaults"})); assert!(h.app.preferences.viewport_analysis_status); h.call("ui.prefs", json!({"button":"cancel"})); @@ -6573,7 +8159,7 @@ mod tests { crate::documents::open_tab(&mut app, other).unwrap(); let before: Vec<_> = std::iter::once(&app.session).chain(app.docs.background()).map(|s| (s.status_items(), s.revision, s.undo.len())).collect(); - super::control(&mut app, &json!({"open":"Interface","viewport_analysis_status":false})).unwrap(); + super::control(&mut app, &json!({"open":"Graphics","viewport_analysis_status":false})).unwrap(); assert!(!app.preferences.viewport_analysis_status); assert!(!app.preferences.persistent_ribbon_pressed); assert!(std::iter::once(&app.session).chain(app.docs.background()).all(|s| !s.single_surface_analysis())); @@ -6592,7 +8178,7 @@ mod tests { let mut h = crate::scenario::Harness::new(); let design = h.app.session.doc.to_json(); let revision = h.app.session.revision; - h.call("ui.prefs", json!({"open":"Interface"})); + h.call("ui.prefs", json!({"open":"Graphics"})); super::reveal_preference(&mut h, "prefs:viewport_section_status"); let checkbox = crate::scenario::handle_at("prefs:viewport_section_status").unwrap(); h.call("ui.click", json!({"x":checkbox.x,"y":checkbox.y})); @@ -6600,10 +8186,10 @@ mod tests { assert_eq!(h.call("ui.prefs", json!({}))["result"]["viewport_section_status"], false); let mut restored = SolveApp::new(Default::default(), Default::default()); restored.load_prefs(&h.app.prefs()); - assert!(!restored.preferences.viewport_section_status); + assert!(restored.preferences.viewport_section_status, "a change that was not applied is not saved (#9730)"); h.call("ui.prefs", json!({"button":"cancel"})); assert!(h.app.preferences.viewport_section_status); - h.call("ui.prefs", json!({"open":"Interface","viewport_section_status":false,"button":"apply"})); + h.call("ui.prefs", json!({"open":"Graphics","viewport_section_status":false,"button":"apply"})); h.call("ui.prefs", json!({"button":"defaults"})); assert!(h.app.preferences.viewport_section_status); h.call("ui.prefs", json!({"button":"cancel"})); @@ -6616,6 +8202,38 @@ mod tests { assert_eq!(h.app.session.doc.to_json(), design); } + #[test] + fn viewport_status_details_preference_checkbox_control_persists_cancels_and_resets() { + assert!(Prefs::default().viewport_status_details); + assert!(lenient(&json!({"theme":"light"})).viewport_status_details); + assert!(lenient(&json!({"viewport_status_details":"no"})).viewport_status_details); + let mut h = crate::scenario::Harness::new(); + let design = h.app.session.doc.to_json(); + let revision = h.app.session.revision; + h.call("ui.prefs", json!({"open":"Graphics"})); + super::reveal_preference(&mut h, "prefs:viewport_status_details"); + let checkbox = crate::scenario::handle_at("prefs:viewport_status_details").unwrap(); + h.call("ui.click", json!({"x":checkbox.x,"y":checkbox.y})); + assert!(!h.app.preferences.viewport_status_details); + assert_eq!(h.call("ui.prefs", json!({}))["result"]["viewport_status_details"], false); + let mut restored = SolveApp::new(Default::default(), Default::default()); + restored.load_prefs(&h.app.prefs()); + assert!(restored.preferences.viewport_status_details, "a change that was not applied is not saved (#9730)"); + h.call("ui.prefs", json!({"button":"cancel"})); + assert!(h.app.preferences.viewport_status_details); + h.call("ui.prefs", json!({"open":"Graphics","viewport_status_details":false,"button":"apply"})); + h.call("ui.prefs", json!({"button":"defaults"})); + assert!(h.app.preferences.viewport_status_details); + h.call("ui.prefs", json!({"button":"cancel"})); + assert!(!h.app.preferences.viewport_status_details); + for malformed in [json!("false"), json!(null), json!(0), json!([])] { + assert!(super::set(&mut h.app, &json!({"viewport_status_details":malformed})).is_err()); + assert!(!h.app.preferences.viewport_status_details); + } + assert_eq!(h.app.session.revision, revision); + assert_eq!(h.app.session.doc.to_json(), design); + } + #[test] fn viewport_status_summary_preference_checkbox_control_persists_cancels_and_resets() { assert!(Prefs::default().viewport_status_summary); @@ -6624,7 +8242,7 @@ mod tests { let mut h = crate::scenario::Harness::new(); let design = h.app.session.doc.to_json(); let revision = h.app.session.revision; - h.call("ui.prefs", json!({"open":"Interface"})); + h.call("ui.prefs", json!({"open":"Graphics"})); super::reveal_preference(&mut h, "prefs:viewport_status_summary"); let checkbox = crate::scenario::handle_at("prefs:viewport_status_summary").unwrap(); h.call("ui.click", json!({"x":checkbox.x,"y":checkbox.y})); @@ -6632,10 +8250,10 @@ mod tests { assert_eq!(h.call("ui.prefs", json!({}))["result"]["viewport_status_summary"], false); let mut restored = SolveApp::new(Default::default(), Default::default()); restored.load_prefs(&h.app.prefs()); - assert!(!restored.preferences.viewport_status_summary); + assert!(restored.preferences.viewport_status_summary, "a change that was not applied is not saved (#9730)"); h.call("ui.prefs", json!({"button":"cancel"})); assert!(h.app.preferences.viewport_status_summary); - h.call("ui.prefs", json!({"open":"Interface","viewport_status_summary":false,"button":"apply"})); + h.call("ui.prefs", json!({"open":"Graphics","viewport_status_summary":false,"button":"apply"})); h.call("ui.prefs", json!({"button":"defaults"})); assert!(h.app.preferences.viewport_status_summary); h.call("ui.prefs", json!({"button":"cancel"})); @@ -6660,7 +8278,7 @@ mod tests { crate::documents::open_tab(&mut app, other).unwrap(); let before: Vec<_> = std::iter::once(&app.session).chain(app.docs.background()).map(|s| (s.status_items(), s.revision, s.undo.len())).collect(); - super::control(&mut app, &json!({"open":"Interface","viewport_section_status":false})).unwrap(); + super::control(&mut app, &json!({"open":"Graphics","viewport_section_status":false})).unwrap(); assert!(!app.preferences.viewport_section_status); assert!(!app.preferences.persistent_ribbon_pressed); assert!(std::iter::once(&app.session).chain(app.docs.background()).all(|s| !s.single_surface_analysis())); @@ -6688,8 +8306,8 @@ mod tests { assert!(!h.app.session.single_surface_analysis()); let mut restored = SolveApp::new(Default::default(), Default::default()); restored.load_prefs(&h.app.prefs()); - assert!(!restored.preferences.single_surface_analysis); - assert!(!restored.session.single_surface_analysis(), "loaded settings apply before any command"); + assert!(restored.preferences.single_surface_analysis, "a change that was not applied is not saved (#9730)"); + assert!(restored.session.single_surface_analysis(), "loaded settings apply before any command"); h.call("ui.prefs", json!({"button":"cancel"})); assert!(h.app.preferences.single_surface_analysis && h.app.session.single_surface_analysis()); h.call("ui.prefs", json!({"open":"Graphics","single_surface_analysis":false,"button":"apply"})); @@ -6782,7 +8400,7 @@ mod tests { assert!(h.app.preferences.cosmetic_threads, "the actual checkbox enables the optional markings"); let mut restored = SolveApp::new(solidcraft_engine::Session::default(), Default::default()); restored.load_prefs(&h.app.prefs()); - assert!(restored.preferences.cosmetic_threads, "the preference survives saved application settings"); + assert!(!restored.preferences.cosmetic_threads, "a change that was not applied is not saved (#9730)"); h.call("ui.prefs", json!({"button":"cancel"})); assert!(!h.app.preferences.cosmetic_threads); h.call("ui.prefs", json!({"open":"Graphics","cosmetic_threads":true,"button":"apply"})); @@ -7166,7 +8784,9 @@ mod tests { #[test] fn language_preference_switches_and_persists() { let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default()); - assert_eq!(app.preferences.language, "en"); + // A first start follows the system (#10138); pin English so the words below are fixed. + assert_eq!(app.preferences.language, crate::i18n::SYSTEM); + app.preferences.language = "en".into(); assert_eq!(app.tr("ui.preferences"), "Preferences"); assert_eq!(app.tr("ui.extrude"), "Extrude"); @@ -7463,7 +9083,7 @@ mod ambient_occlusion_tests { assert!(h.app.preferences.ambient_occlusion); let mut restored = SolveApp::new(solidcraft_engine::Session::default(), Default::default()); restored.load_prefs(&h.app.prefs()); - assert!(restored.preferences.ambient_occlusion); + assert!(!restored.preferences.ambient_occlusion, "a change that was not applied is not saved (#9730)"); h.call("ui.prefs", json!({"button":"cancel"})); assert!(!h.app.preferences.ambient_occlusion); h.call("ui.prefs", json!({"open":"Graphics", "ambient_occlusion":true})); @@ -7482,6 +9102,31 @@ mod ambient_occlusion_tests { assert_eq!(h.app.session.doc.to_json(), design); assert!(h.app.session.undo.is_empty()); } + + /// #7974: shading dark parts in linear light with a sky fill is off for older and new + /// preferences, turned on from the Graphics page, kept, and sent to the shader with the world's + /// up direction; Restore Defaults turns it off again. + #[test] + fn dark_parts_shading_is_optional_persistent_and_follows_the_up_axis() { + use solidcraft_engine::render::UpAxis; + let old: Prefs = serde_json::from_value(json!({"msaa": 4})).unwrap(); + assert!(!old.dark_parts_shading && !Prefs::default().dark_parts_shading); + assert_eq!(crate::viewport::sky_uniform(false, UpAxis::Z), [0.0; 4], "off: shaded as before"); + assert_eq!(crate::viewport::sky_uniform(true, UpAxis::Z), [0.0, 0.0, 1.0, 1.0]); + assert_eq!(crate::viewport::sky_uniform(true, UpAxis::Y), [0.0, 1.0, 0.0, 1.0]); + let mut h = crate::scenario::Harness::new(); + h.call("ui.prefs", json!({"open":"Graphics"})); + super::reveal_preference(&mut h, "prefs:dark_parts_shading"); + let checkbox = crate::scenario::handle_at("prefs:dark_parts_shading").unwrap(); + h.call("ui.click", json!({"x":checkbox.x,"y":checkbox.y})); + assert!(h.app.preferences.dark_parts_shading); + h.call("ui.prefs", json!({"button":"apply"})); + let mut restored = SolveApp::new(solidcraft_engine::Session::default(), Default::default()); + restored.load_prefs(&h.app.prefs()); + assert!(restored.preferences.dark_parts_shading); + h.call("ui.prefs", json!({"button":"defaults"})); + assert!(!h.app.preferences.dark_parts_shading); + } } /// Manual edits are preserved and shown as Custom rather than claiming to match a preset. @@ -7527,7 +9172,7 @@ mod graphics_preset_tests { assert!(h.app.preferences.ambient_occlusion); let mut restored = SolveApp::new(solidcraft_engine::Session::default(), Default::default()); restored.load_prefs(&h.app.prefs()); - assert!(restored.preferences.ambient_occlusion); + assert!(!restored.preferences.ambient_occlusion, "a change that was not applied is not saved (#9730)"); h.call("ui.prefs", json!({"button":"cancel"})); assert_eq!(graphics_preset(&h.app.preferences), "performance"); assert!(!h.app.preferences.ambient_occlusion); @@ -7545,6 +9190,9 @@ mod graphics_preset_tests { assert_eq!(h.app.preferences.display_quality, "fine"); assert_eq!(h.app.preferences.supersampling, 2.0); assert!(h.app.preferences.supersampling_adaptive); + // The dialog is closed now: what is saved is the live state, quality included (#9730). + let mut restored = SolveApp::new(solidcraft_engine::Session::default(), Default::default()); + restored.load_prefs(&h.app.prefs()); for edit in [ json!({"display_quality":"ultra"}), json!({"supersampling":1.5}), diff --git a/crates/ui-egui/src/prefs/design.rs b/crates/ui-egui/src/prefs/design.rs index a98cf2ea28..e2448e0e3e 100644 --- a/crates/ui-egui/src/prefs/design.rs +++ b/crates/ui-egui/src/prefs/design.rs @@ -82,66 +82,39 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) { }); }, ); - setting::extrude_auto_to_object(app, ui); - let auto = setting::auto_operation(app, ui); - crate::scenario::publish_handle("prefs:auto_operation", auto.rect.center()); - ui_kit::text(ui, app.tr("prefs.auto_operation_note"), ui_kit::Text::Hint("auto_operation")); - setting::hide_origin(app, ui); - ui_kit::text(ui, app.tr("ui.hide_origin_note"), ui_kit::Text::Hint("hide_origin")); - }); - ui_kit::settings_section(ui, "joint_move", app.tr("joint_move.settings"), |ui| { - setting::assembly_move_handles(app, ui); - ui_kit::text(ui, app.tr("joint_move.legacy_hint"), ui_kit::Text::Hint("joint_move_legacy")); - ui_kit::settings_form(ui, t.space_24 * 7.0 + t.space_2, |ui| { - ui_kit::row(ui, app.tr("joint_move.coarse"), |ui| { - ui_kit::settings_number(ui, &mut app.preferences.joint_rotation_coarse, 0.001..=180.0, 0.1); - }); - ui_kit::row(ui, app.tr("joint_move.fine"), |ui| { - ui_kit::settings_number(ui, &mut app.preferences.joint_rotation_fine, 0.001..=180.0, 0.01); - }); - }); - ui_kit::text(ui, app.tr("joint_move.precision"), ui_kit::Text::Hint("joint_move")); - }); - // The Hole command's own settings, under its name. - ui_kit::settings_section(ui, "create_hole", crate::i18n::tr_label("Create Hole"), |ui| { - setting::hole_snaps(app, ui); - ui_kit::text( - ui, - crate::i18n::tr_label("A Single hole snaps to the middle of its face and to the centres of its round edges"), - ui_kit::Text::Hint("hole_snaps"), - ); - setting::hole_moves(app, ui); - ui_kit::text( - ui, - crate::i18n::tr_label( - "A placed Single hole has a handle to drag it, and picking its face again moves the hole there instead of taking it out", - ), - ui_kit::Text::Hint("hole_moves"), - ); - }); - ui_kit::settings_section(ui, "placement", app.tr("prefs.placement"), |ui| { - setting::placement_handles(app, ui); }); + origin_plane_names(app, ui); ui_kit::settings_section(ui, "linked_designs", app.tr("prefs.linked_designs"), |ui| { setting::update_changed_links(app, ui); setting::double_click_components(app, ui); setting::insert_lists_open_designs(app, ui); }); - // Where the files a design names may be: never a network location the user hasn't allowed. - ui_kit::settings_section(ui, "network_locations", app.tr("prefs.network_locations_section"), |ui| { - setting::network_locations(app, ui); - ui_kit::text(ui, app.tr("prefs.network_locations_note"), ui_kit::Text::Hint("network_locations")); - let mut remove = None; - for (i, host) in app.preferences.network_hosts.iter().enumerate() { - ui.horizontal(|ui| { - ui_kit::text(ui, &format!("\\\\{host}"), ui_kit::Text::Hint("network_host")); - if ui_kit::button(ui, app.tr("prefs.network_host_remove"), ui_kit::Button::Small).clicked() { - remove = Some(i); - } - }); +} + +/// Origin plane names: each choice shows the three names it gives (`XY / XZ / YZ`), with a note +/// that only the names shown change. +fn origin_plane_names(app: &mut SolveApp, ui: &mut egui::Ui) { + ui_kit::settings_section(ui, "origin_plane_names", app.tr("prefs.origin_plane_names"), |ui| { + let choices: Vec = + OriginPlaneNames::ALL.iter().map(|names| OriginPlaneNames::PLANES.map(|plane| names.label(plane)).join(" / ")).collect(); + let options: Vec<&str> = choices.iter().map(String::as_str).collect(); + let mut selected = OriginPlaneNames::ALL.iter().position(|names| *names == app.preferences.origin_plane_names).unwrap_or(0); + if ui_kit::settings_segments(ui, app.tr("prefs.origin_plane_names"), &mut selected, &options) + && let Some(names) = OriginPlaneNames::ALL.get(selected) + { + app.preferences.origin_plane_names = *names; } - if let Some(i) = remove.filter(|i| *i < app.preferences.network_hosts.len()) { - app.preferences.network_hosts.remove(i); + // Handles that don't depend on the language, by the choice's saved value. + for (choice, key) in choices.iter().zip(["xyz", "top_front_right"]) { + if let Some(at) = crate::scenario::handle_at(&format!("dialog:{}:{choice}", app.tr("prefs.origin_plane_names"))) { + crate::scenario::publish_handle(&format!("prefs:origin_plane_names:{key}"), at); + } } + let top = OriginPlaneNames::TopFrontRight; + let note = + crate::i18n::format("prefs.origin_plane_names_note", &[("top", top.label("XY")), ("front", top.label("XZ")), ("right", top.label("YZ"))]); + ui_kit::text(ui, ¬e, ui_kit::Text::Hint("origin_plane_names")); + setting::hide_origin(app, ui); + ui_kit::text(ui, app.tr("ui.hide_origin_note"), ui_kit::Text::Hint("hide_origin")); }); } diff --git a/crates/ui-egui/src/prefs/general.rs b/crates/ui-egui/src/prefs/general.rs index 1bd2564cee..934a664bc5 100644 --- a/crates/ui-egui/src/prefs/general.rs +++ b/crates/ui-egui/src/prefs/general.rs @@ -4,23 +4,42 @@ use super::*; pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) { let t = Tokens::get(); let labels = t.space_24 * 6.0 + t.space_6; - ui_kit::settings_heading(ui, app.tr("prefs.general"), app.tr("prefs.general_note")); + ui_kit::settings_heading_with_tag(ui, app.tr("prefs.general"), app.tr("prefs.general_note"), Some(crate::settings_profiles::tag)); + crate::settings_profiles::section(app, ui, labels); ui_kit::settings_section(ui, "language", app.tr("ui.language"), |ui| { ui_kit::settings_form(ui, labels, |ui| { ui_kit::row(ui, app.tr("prefs.interface_language"), |ui| { ui_kit::settings_control_width(ui, t.form_card_width - t.space_4, |ui| { - ui_kit::dropdown_menu(ui, "pref_language", &crate::i18n::Locale::for_language(&app.preferences.language).display_name(), |ui| { - for locale in crate::i18n::LOCALES { - if ui_kit::list_picker(ui, locale.id, &locale.display_name(), app.preferences.language == locale.id).clicked() { - app.preferences.language = locale.id.into(); + ui_kit::dropdown_menu( + ui, + "pref_language", + &if app.preferences.language == crate::i18n::SYSTEM { + app.tr("prefs.language_system").to_owned() + } else { + crate::i18n::Locale::for_language(&app.preferences.language).display_name().into_owned() + }, + |ui| { + if ui_kit::list_picker( + ui, + crate::i18n::SYSTEM, + app.tr("prefs.language_system"), + app.preferences.language == crate::i18n::SYSTEM, + ) + .clicked() + { + app.preferences.language = crate::i18n::SYSTEM.into(); } - } - }); + for locale in crate::i18n::LOCALES { + if ui_kit::list_picker(ui, locale.id, &locale.display_name(), app.preferences.language == locale.id).clicked() { + app.preferences.language = locale.id.into(); + } + } + }, + ); }); }); }); }); - crate::settings_profiles::section(app, ui, labels); ui_kit::settings_section(ui, "saving", app.tr("prefs.saving"), |ui| { ui_kit::settings_block_hint(ui, app.tr("ui.autosave_every"), crate::i18n::tr_label("0 turns it off"), labels, |ui| { ui.horizontal(|ui| { @@ -51,6 +70,23 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) { }); }); ui_kit::settings_section(ui, "file_size", app.tr("prefs.max_file_size"), |ui| file_size_rows(app, ui, labels)); + // Where the files a design names may be: never a network location the user hasn't allowed. + ui_kit::settings_section(ui, "network_locations", app.tr("prefs.network_locations_section"), |ui| { + setting::network_locations(app, ui); + ui_kit::text(ui, app.tr("prefs.network_locations_note"), ui_kit::Text::Hint("network_locations")); + let mut remove = None; + for (i, host) in app.preferences.network_hosts.iter().enumerate() { + ui.horizontal(|ui| { + ui_kit::text(ui, &format!("\\\\{host}"), ui_kit::Text::Hint("network_host")); + if ui_kit::button(ui, app.tr("prefs.network_host_remove"), ui_kit::Button::Small).clicked() { + remove = Some(i); + } + }); + } + if let Some(i) = remove.filter(|i| *i < app.preferences.network_hosts.len()) { + app.preferences.network_hosts.remove(i); + } + }); ui_kit::settings_section(ui, "slicer", app.tr("ui.slicer"), |ui| { ui_kit::settings_block(ui, app.tr("ui.slicer"), labels, |ui| { let (single, tip) = (app.tr("ui.slicer_single_instance").to_string(), app.tr("ui.slicer_single_instance_tip").to_string()); diff --git a/crates/ui-egui/src/prefs/graphics.rs b/crates/ui-egui/src/prefs/graphics.rs index ac2414171c..d4d42d4361 100644 --- a/crates/ui-egui/src/prefs/graphics.rs +++ b/crates/ui-egui/src/prefs/graphics.rs @@ -31,6 +31,13 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) { ui_kit::text(ui, app.tr("prefs.graphics_card_restart"), ui_kit::Text::Hint("graphics_card_restart")); if missing { ui_kit::text(ui, app.tr("prefs.graphics_card_missing"), ui_kit::Text::Hint("graphics_card_missing")); + } else if !app.preferences.graphics_adapter.is_empty() { + // Status / Help show the adapter actually in use; the dropdown shows the saved + // preference. When they differ the saved card was not applied (#8341). + let active = app.viewport.adapter.rsplit(" · ").next().unwrap_or(""); + if !active.is_empty() && !active.eq_ignore_ascii_case(&app.preferences.graphics_adapter) { + ui_kit::text(ui, app.tr("prefs.graphics_card_unused"), ui_kit::Text::Hint("graphics_card_unused")); + } } }); } @@ -149,6 +156,8 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) { } }); ui_kit::text(ui, app.tr("prefs.contact_shading_note"), ui_kit::Text::Hint("ao")); + let response = setting::dark_parts_shading(app, ui).on_hover_text(app.tr("prefs.dark_parts_shading_help")); + crate::scenario::publish_handle("prefs:dark_parts_shading", response.rect.center()); }); ui_kit::settings_section(ui, "viewport", app.tr("prefs.viewport"), |ui| { let analysis_policy = setting::single_surface_analysis(app, ui).on_hover_text(app.tr("prefs.single_surface_analysis_help")); @@ -180,12 +189,29 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) { right.add_enabled_ui(app.ui.show_grid, |ui| { setting::grid_follows_view(app, ui); }); - let response = setting::measure_axis_distances(app, right); - crate::scenario::publish_rect("prefs:measure_axis_distances", response.rect); - response.on_hover_text(app.tr("settings.measure_axis_distances_help")); } }); ui_kit::settings_form(ui, t.space_24 * 6.0 + t.space_6, |ui| { + ui_kit::row(ui, "", |ui| { + let check = setting::quick_measure(app, ui).on_hover_text(app.tr("prefs.quick_measure_note")); + crate::scenario::publish_target("prefs:quick_measure", check.rect); + }); + ui_kit::row(ui, "", |ui| { + let check = setting::viewport_analysis_status(app, ui).on_hover_text(app.tr("prefs.viewport_analysis_status_help")); + crate::scenario::publish_target("prefs:viewport_analysis_status", check.rect); + }); + ui_kit::row(ui, "", |ui| { + let check = setting::viewport_section_status(app, ui).on_hover_text(app.tr("prefs.viewport_section_status_help")); + crate::scenario::publish_target("prefs:viewport_section_status", check.rect); + }); + ui_kit::row(ui, "", |ui| { + let check = setting::viewport_status_details(app, ui).on_hover_text(app.tr("prefs.viewport_status_details_help")); + crate::scenario::publish_target("prefs:viewport_status_details", check.rect); + }); + ui_kit::row(ui, "", |ui| { + let check = setting::viewport_status_summary(app, ui).on_hover_text(app.tr("prefs.viewport_status_summary_help")); + crate::scenario::publish_target("prefs:viewport_status_summary", check.rect); + }); ui_kit::row(ui, app.tr_label("Visual style"), |ui| { let mut selected = usize::from(app.ui.visual_style.min(3)); ui_kit::dropdown(ui, "pref_style", &mut selected, &["Shaded with edges", "Shaded", "Wireframe", "Shaded with hidden edges"]); diff --git a/crates/ui-egui/src/prefs/interface.rs b/crates/ui-egui/src/prefs/interface.rs index 60f4add0cb..06e7e8e559 100644 --- a/crates/ui-egui/src/prefs/interface.rs +++ b/crates/ui-egui/src/prefs/interface.rs @@ -1,36 +1,16 @@ -//! Interface settings composition, using shared controls and previews. +//! Appearance and Layout settings composition, using shared controls and previews. use super::*; -/// Origin plane names: each choice shows the three names it gives (`XY / XZ / YZ`), with a note -/// that only the names shown change. -fn origin_plane_names(app: &mut SolveApp, ui: &mut egui::Ui) { - ui_kit::settings_section(ui, "origin_plane_names", app.tr("prefs.origin_plane_names"), |ui| { - let choices: Vec = - OriginPlaneNames::ALL.iter().map(|names| OriginPlaneNames::PLANES.map(|plane| names.label(plane)).join(" / ")).collect(); - let options: Vec<&str> = choices.iter().map(String::as_str).collect(); - let mut selected = OriginPlaneNames::ALL.iter().position(|names| *names == app.preferences.origin_plane_names).unwrap_or(0); - if ui_kit::settings_segments(ui, app.tr("prefs.origin_plane_names"), &mut selected, &options) - && let Some(names) = OriginPlaneNames::ALL.get(selected) - { - app.preferences.origin_plane_names = *names; - } - // Handles that don't depend on the language, by the choice's saved value. - for (choice, key) in choices.iter().zip(["xyz", "top_front_right"]) { - if let Some(at) = crate::scenario::handle_at(&format!("dialog:{}:{choice}", app.tr("prefs.origin_plane_names"))) { - crate::scenario::publish_handle(&format!("prefs:origin_plane_names:{key}"), at); - } - } - let top = OriginPlaneNames::TopFrontRight; - let note = - crate::i18n::format("prefs.origin_plane_names_note", &[("top", top.label("XY")), ("front", top.label("XZ")), ("right", top.label("YZ"))]); - ui_kit::text(ui, ¬e, ui_kit::Text::Hint("origin_plane_names")); - }); -} - -pub(super) fn interface(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) { - ui_kit::settings_heading(ui, app.tr("prefs.interface"), app.tr("prefs.interface_note")); +pub(super) fn appearance(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) { + ui_kit::settings_heading_with_tag( + ui, + app.tr("prefs.structure.appearance"), + app.tr("prefs.structure.appearance_note"), + Some(crate::settings_profiles::tag), + ); ui_kit::settings_section(ui, "theme", app.tr("ui.theme"), |ui| { theme_row(app, ui); + theme_options_row(app, ui); ui_kit::tab_accent_strip(ui); // The 3D view in the theme's colours, or the Light or Dark theme's (#4146). ui_kit::settings_form(ui, t.form_label_width + t.space_16 + t.space_6, |ui| { @@ -72,6 +52,97 @@ pub(super) fn interface(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) { } }); }); + ui_kit::settings_section(ui, "scale", app.tr("prefs.scale_text"), |ui| { + ui_kit::settings_pair( + ui, + |ui| { + let response = ui_kit::slider( + ui, + &mut app.preferences.ui_scale, + 0.5..=2.5, + ui_kit::Slider { step: Some(0.05), format: |v| format!("{:.0} %", v * 100.0), ..Default::default() }, + ); + crate::scenario::publish_target("prefs:scale", response.rect); + ui.horizontal_wrapped(|ui| { + let locale = crate::i18n::Locale::for_language(&app.preferences.language); + for value in [0.75, 1.0, 1.1, 1.25, 1.5, 2.0] { + let percent = format!("{:.0}", value * 100.0); + if ui_kit::button( + ui, + locale.format("prefs.structure.scale_percent", &[("percent", &percent)]), + if (app.preferences.ui_scale - value).abs() < 0.001 { ui_kit::Button::SmallSelected } else { ui_kit::Button::Small }, + ) + .clicked() + { + app.preferences.ui_scale = value; + } + } + }); + ui_kit::text(ui, app.tr("prefs.scale_note"), ui_kit::Text::Hint("scale")); + app.preferences.ui_scale + }, + ui_kit::scale_specimen, + ); + ui_kit::divider(ui, "scale_font"); + if !cfg!(target_arch = "wasm32") { + ui_kit::settings_font_row(ui, crate::i18n::tr("prefs.font"), |ui, part| { + if part == 0 { + ui_font_row(app, ui); + return; + } + if ui_kit::button(ui, app.tr("prefs.choose_font"), ui_kit::Button::Normal).clicked() { + app.pick_open_for(&["ttf", "otf", "ttc"], |app, path| { + if let Some(path) = path { + if crate::fonts::ui_font(&path).is_some() { + app.preferences.ui_font = path; + } else { + app.set_status(crate::i18n::tr("prefs.font_unreadable"), true); + } + } + }); + } + if ui_kit::button(ui, app.tr_label("Reset"), ui_kit::Button::Ghost).clicked() { + app.preferences.ui_font.clear(); + } + }); + ui_kit::font_specimen(ui, app.tr("prefs.font_sample")); + ui_kit::text(ui, app.tr("prefs.font_fallback"), ui_kit::Text::Hint("font_fallback")); + } + }); + ui_kit::settings_section(ui, "colour_vision", app.tr("ui.colour_vision"), |ui| { + let mode = app.preferences.color_vision; + ui.horizontal_wrapped(|ui| { + let modes = crate::theme::ColorVision::ALL; + let labels: Vec<_> = modes.iter().map(|m| crate::i18n::tr(m.label_id())).collect(); + let mut selected = modes.iter().position(|m| *m == mode).unwrap_or(0); + if ui_kit::settings_segments(ui, app.tr("ui.colour_vision_mode"), &mut selected, &labels) + && let Some(mode) = modes.get(selected) + { + app.preferences.color_vision = *mode; + } + if let Some(rect) = crate::scenario::rect_of(&format!("dialog:segments:{}", app.tr("ui.colour_vision_mode"))) { + crate::scenario::publish_target("prefs:color_vision", rect); + } + ui_kit::status_palette_sample(ui, app.preferences.color_vision); + }); + // What the modes recolour, in the mode being chosen (#7152): the status chips above keep + // their colours in every mode. + ui_kit::colour_vision_sample(ui, &crate::theme::Tokens::get_in(app.preferences.color_vision)); + if app.preferences.color_vision != mode { + ui.ctx().request_repaint(); + } + ui_kit::text(ui, app.tr("ui.colour_vision_note"), ui_kit::Text::Hint("colour_vision")); + }); +} + +pub(super) fn layout(app: &mut SolveApp, ui: &mut egui::Ui) { + let t = Tokens::get(); + ui_kit::settings_heading_with_tag( + ui, + app.tr("prefs.structure.layout"), + app.tr("prefs.structure.layout_note"), + Some(crate::settings_profiles::tag), + ); ui_kit::settings_section(ui, "layout", app.tr("prefs.layout"), |ui| { ui_kit::settings_pair( ui, @@ -98,23 +169,16 @@ pub(super) fn interface(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) { let check = setting::persistent_ribbon_pressed(app, ui).on_hover_text(app.tr("prefs.persistent_ribbon_pressed_help")); crate::scenario::publish_target("prefs:persistent_ribbon_pressed", check.rect); }); - ui_kit::row(ui, "", |ui| { - let check = setting::viewport_analysis_status(app, ui).on_hover_text(app.tr("prefs.viewport_analysis_status_help")); - crate::scenario::publish_target("prefs:viewport_analysis_status", check.rect); - }); - ui_kit::row(ui, "", |ui| { - let check = setting::viewport_section_status(app, ui).on_hover_text(app.tr("prefs.viewport_section_status_help")); - crate::scenario::publish_target("prefs:viewport_section_status", check.rect); - }); - ui_kit::row(ui, "", |ui| { - let check = setting::viewport_status_summary(app, ui).on_hover_text(app.tr("prefs.viewport_status_summary_help")); - crate::scenario::publish_target("prefs:viewport_status_summary", check.rect); - }); // The SKETCH tab outside a sketch too (#7904). ui_kit::row(ui, "", |ui| { let check = setting::sketch_tab_always(app, ui).on_hover_text(app.tr("prefs.sketch_tab_always_note")); crate::scenario::publish_target("prefs:sketch_tab_always", check.rect); }); + // Each design tab keeps its dialog, tool and toolbar tab across a switch (#9360). + ui_kit::row(ui, "", |ui| { + let check = setting::keep_work_on_tab_switch(app, ui).on_hover_text(app.tr("prefs.keep_work_on_tab_switch_note")); + crate::scenario::publish_target("prefs:keep_work_on_tab_switch", check.rect); + }); // Search results point out their place in the ribbon (#7287). ui_kit::row(ui, "", |ui| { let check = setting::ribbon_reveal(app, ui).on_hover_text(app.tr("prefs.ribbon_reveal_note")); @@ -287,11 +351,6 @@ pub(super) fn interface(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) { let check = setting::status_bar(app, ui).on_hover_text(app.tr("prefs.status_bar_note")); crate::scenario::publish_target("prefs:status_bar", check.rect); }); - // The selection's measurement in the view's corner (#7000). - ui_kit::row(ui, "", |ui| { - let check = setting::quick_measure(app, ui).on_hover_text(app.tr("prefs.quick_measure_note")); - crate::scenario::publish_target("prefs:quick_measure", check.rect); - }); ui_kit::text(ui, app.tr("prefs.layout_note"), ui_kit::Text::Hint("layout")); ui_kit::text(ui, app.tr("prefs.history_sketches_note"), ui_kit::Text::Hint("history_sketches")); ui_kit::LayoutPreview { @@ -311,7 +370,6 @@ pub(super) fn interface(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) { ui_kit::layout_preview, ); }); - origin_plane_names(app, ui); super::navbar::show(app, ui); // The same choice as the toolbar's own menu (the plus after the tabs, or right-click a tab). ui_kit::settings_section(ui, "toolbar_tabs", app.tr("prefs.toolbar_tabs"), |ui| { @@ -324,83 +382,4 @@ pub(super) fn interface(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) { } } }); - ui_kit::settings_section(ui, "scale", app.tr("prefs.scale_text"), |ui| { - ui_kit::settings_pair( - ui, - |ui| { - let response = ui_kit::slider( - ui, - &mut app.preferences.ui_scale, - 0.5..=2.5, - ui_kit::Slider { step: Some(0.05), format: |v| format!("{:.0} %", v * 100.0), ..Default::default() }, - ); - crate::scenario::publish_target("prefs:scale", response.rect); - ui.horizontal_wrapped(|ui| { - for value in [0.75, 1.0, 1.1, 1.25, 1.5, 2.0] { - if ui_kit::button( - ui, - format!("{:.0}%", value * 100.0), - if (app.preferences.ui_scale - value).abs() < 0.001 { ui_kit::Button::SmallSelected } else { ui_kit::Button::Small }, - ) - .clicked() - { - app.preferences.ui_scale = value; - } - } - }); - ui_kit::text(ui, app.tr("prefs.scale_note"), ui_kit::Text::Hint("scale")); - app.preferences.ui_scale - }, - ui_kit::scale_specimen, - ); - ui_kit::divider(ui, "scale_font"); - if !cfg!(target_arch = "wasm32") { - ui_kit::settings_font_row(ui, crate::i18n::tr("prefs.font"), |ui, part| { - if part == 0 { - ui_font_row(app, ui); - return; - } - if ui_kit::button(ui, app.tr("prefs.choose_font"), ui_kit::Button::Normal).clicked() { - app.pick_open_for(&["ttf", "otf", "ttc"], |app, path| { - if let Some(path) = path { - if crate::fonts::ui_font(&path).is_some() { - app.preferences.ui_font = path; - } else { - app.set_status(crate::i18n::tr("prefs.font_unreadable"), true); - } - } - }); - } - if ui_kit::button(ui, app.tr_label("Reset"), ui_kit::Button::Ghost).clicked() { - app.preferences.ui_font.clear(); - } - }); - ui_kit::font_specimen(ui, app.tr("prefs.font_sample")); - ui_kit::text(ui, app.tr("prefs.font_fallback"), ui_kit::Text::Hint("font_fallback")); - } - }); - ui_kit::settings_section(ui, "colour_vision", app.tr("ui.colour_vision"), |ui| { - let mode = app.preferences.color_vision; - ui.horizontal_wrapped(|ui| { - let modes = crate::theme::ColorVision::ALL; - let labels: Vec<_> = modes.iter().map(|m| crate::i18n::tr(m.label_id())).collect(); - let mut selected = modes.iter().position(|m| *m == mode).unwrap_or(0); - if ui_kit::settings_segments(ui, app.tr("ui.colour_vision_mode"), &mut selected, &labels) - && let Some(mode) = modes.get(selected) - { - app.preferences.color_vision = *mode; - } - if let Some(rect) = crate::scenario::rect_of(&format!("dialog:segments:{}", app.tr("ui.colour_vision_mode"))) { - crate::scenario::publish_target("prefs:color_vision", rect); - } - ui_kit::status_palette_sample(ui, app.preferences.color_vision); - }); - // What the modes recolour, in the mode being chosen (#7152): the status chips above keep - // their colours in every mode. - ui_kit::colour_vision_sample(ui, &crate::theme::Tokens::get_in(app.preferences.color_vision)); - if app.preferences.color_vision != mode { - ui.ctx().request_repaint(); - } - ui_kit::text(ui, app.tr("ui.colour_vision_note"), ui_kit::Text::Hint("colour_vision")); - }); } diff --git a/crates/ui-egui/src/prefs/navigation.rs b/crates/ui-egui/src/prefs/navigation.rs index 2cd0d504a3..070035b5f5 100644 --- a/crates/ui-egui/src/prefs/navigation.rs +++ b/crates/ui-egui/src/prefs/navigation.rs @@ -2,7 +2,7 @@ use super::*; pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) { - ui_kit::settings_heading(ui, app.tr("prefs.navigation"), app.tr("prefs.navigation_note")); + ui_kit::settings_heading_with_tag(ui, app.tr("prefs.navigation"), app.tr("prefs.structure.navigation_note"), Some(crate::settings_profiles::tag)); ui_kit::settings_section(ui, "mouse", app.tr("prefs.mouse_heading"), |ui| { let mut gestures: Vec<_> = nav_preset(&app.preferences.nav) .bindings @@ -137,7 +137,6 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) { .on_hover_text(if touchpad { crate::i18n::tr("nav.touchpad.scroll") } else { crate::i18n::tr("ui.trackpad_pan_tip") }); }); setting::radial_context_menu(app, ui); - setting::direct_interaction(app, ui); }); }); ui_kit::row(ui, app.tr("prefs.trackpad_zoom_modifier"), |ui| { @@ -159,11 +158,20 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) { }) .on_hover_text(crate::i18n::tr("prefs.trackpad_zoom_modifier.help")); }); - let cursor = ui_kit::checkbox(ui, crate::i18n::tr_label("Show tool at cursor"), &mut app.ui.tool_cursor); - crate::scenario::publish_target("view:tool_cursor", cursor.rect); ui_kit::divider(ui, "navigation_view_controls"); ui_kit::settings_form(ui, 150.0, |ui| { setting::wheel_zoom_speed(app, ui); + let (label, note) = (app.tr("prefs.home_fit_size").to_owned(), app.tr("prefs.home_fit_size_note").to_owned()); + ui_kit::row(ui, &label, |ui| { + let response = ui_kit::slider( + ui, + &mut app.preferences.home_fit_size, + 0.1..=1.0, + ui_kit::Slider { step: Some(0.05), format: |v| format!("{:.0} %", v * 100.0), ..Default::default() }, + ); + crate::scenario::publish_rect("prefs:home_fit_size", response.rect); + response.on_hover_text(¬e) + }); ui_kit::row(ui, app.tr_label("View navigator"), |ui| { let mut selected = usize::from(app.preferences.navigator_style == NavigatorStyle::Cube); if ui_kit::segmented( @@ -186,6 +194,12 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) { } } }); + let (label, note) = (app.tr("prefs.navigator_fit").to_owned(), app.tr("prefs.navigator_fit_note").to_owned()); + let fit = ui_kit::checkbox(ui, &label, &mut app.preferences.navigator_fit).on_hover_text(note); + crate::scenario::publish_target("prefs:navigator_fit", fit.rect); + let (label, note) = (app.tr("prefs.invert_navigator_arrows").to_owned(), app.tr("prefs.invert_navigator_arrows_note").to_owned()); + let invert = ui_kit::checkbox(ui, &label, &mut app.preferences.invert_navigator_arrows).on_hover_text(note); + crate::scenario::publish_target("prefs:invert_navigator_arrows", invert.rect); ui_kit::row(ui, app.tr_label("Middle double-click"), |ui| { let current = MiddleDoubleClick::of(app); ui_kit::settings_control_width(ui, t.form_label_width * 2.0 + t.space_24 * 2.0 + t.space_12, |ui| { @@ -204,31 +218,4 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) { // The existing ring editor is part of the real product and remains available. crate::mouse_gesture::nav_rows(app, ui, t); }); - // The 3D mouse is a section like the ones above: the driver, calibration, axes, picture and buttons - // (`prefs::space_mouse_section`). - space_mouse_section(app, ui); -} - -/// A device's own app on this computer driving the view (the navigation bridge): on or off, its -/// port, and whether an app is connected. Part of the 3D mouse section (`space_mouse_rows`). -pub(super) fn nav_bridge(app: &mut SolveApp, ui: &mut egui::Ui) { - let (label, tip, port) = (app.tr("ui.nav_bridge").to_owned(), app.tr("ui.nav_bridge_tip").to_owned(), app.tr("ui.nav_bridge_port").to_owned()); - let status = match (&app.space_mouse.bridge, app.preferences.nav_bridge_enabled) { - (_, false) => None, - (Some(name), true) => Some((format!("{} · {name}", app.tr("ui.nav_bridge_connected")), false)), - (None, true) => Some((app.tr("ui.nav_bridge_waiting").to_owned(), true)), - }; - ui.horizontal_wrapped(|ui| { - ui_kit::checkbox(ui, &label, &mut app.preferences.nav_bridge_enabled).on_hover_text(&tip); - if let Some((status, waiting)) = &status { - ui_kit::settings_badge(ui, status, *waiting); - } - }); - ui_kit::settings_form(ui, FORM_LABEL_WIDTH, |ui| { - ui_kit::row(ui, &port, |ui| { - ui.add_enabled_ui(app.preferences.nav_bridge_enabled, |ui| { - ui_kit::number_field(ui, &mut app.preferences.nav_bridge_port, 1..=u16::MAX, 1.0, ""); - }); - }); - }); } diff --git a/crates/ui-egui/src/prefs/sketch.rs b/crates/ui-egui/src/prefs/sketch.rs index 2cd6c76ddb..23210022d4 100644 --- a/crates/ui-egui/src/prefs/sketch.rs +++ b/crates/ui-egui/src/prefs/sketch.rs @@ -3,10 +3,19 @@ use super::*; pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) { let t = Tokens::get(); - ui_kit::settings_heading(ui, app.tr("prefs.sketch_selection_heading"), app.tr("prefs.sketch_selection_note")); + ui_kit::settings_heading_with_tag( + ui, + app.tr("prefs.structure.sketch"), + app.tr("prefs.sketch_selection_note"), + Some(crate::settings_profiles::tag), + ); ui_kit::settings_section(ui, "sketch", app.tr("prefs.sketching"), |ui| { ui_kit::checkbox(ui, app.tr("prefs.sketch_tracking_guides"), &mut app.preferences.sketch_tracking_guides) .on_hover_text(app.tr("prefs.sketch_tracking_guides_note")); + ui.add_enabled_ui(app.preferences.sketch_tracking_guides, |ui| { + ui_kit::checkbox(ui, app.tr("prefs.sketch_tracking_points"), &mut app.preferences.sketch_tracking_points) + .on_hover_text(app.tr("prefs.sketch_tracking_points_note")); + }); ui_kit::checkbox(ui, app.tr("prefs.sketch_recent_snap"), &mut app.preferences.sketch_recent_snap) .on_hover_text(app.tr("prefs.sketch_recent_snap_note")); ui_kit::checkbox(ui, app.tr("prefs.sketch_align_points"), &mut app.preferences.sketch_align_points) @@ -15,12 +24,16 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) { if ui_kit::checkbox(ui, crate::i18n::tr_label("Project face edges into new sketches"), &mut auto).changed() { let _ = app.run("sketch.auto_project", json!({"value": auto})); } + let owner = setting::sketch_face_activates_part(app, ui).on_hover_text(app.tr("prefs.sketch_face_activates_part_note")); + crate::scenario::publish_target("prefs:sketch_face_activates_part", owner.rect); ui_kit::checkbox(ui, app.tr("prefs.project_options_dialog"), &mut app.preferences.project_options_dialog) .on_hover_text(app.tr("prefs.project_options_dialog_note")); ui_kit::checkbox(ui, crate::i18n::tr_label("Pressing a tool's shortcut again switches its mode"), &mut app.preferences.shortcut_cycles_modes) .on_hover_text(crate::i18n::tr_label( "Before the first point, pressing a sketch tool's shortcut again steps through its creation modes. Off: it starts the tool again.", )); + let ends_tool = setting::ribbon_click_ends_tool(app, ui).on_hover_text(app.tr("prefs.ribbon_click_ends_tool_note")); + crate::scenario::publish_target("prefs:ribbon_click_ends_tool", ends_tool.rect); // Sketch icons in the colour of their solve state (#7245). let check = setting::sketch_state_icons(app, ui).on_hover_text(app.tr("prefs.sketch_state_icons_note")); crate::scenario::publish_target("prefs:sketch_state_icons", check.rect); @@ -38,6 +51,8 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) { )); let line_drag = setting::line_drag_draws_one(app, ui).on_hover_text(app.tr("prefs.line_drag_draws_one_note")); crate::scenario::publish_target("prefs:line_drag_draws_one", line_drag.rect); + let line_arc = setting::line_hover_tangent_arc(app, ui).on_hover_text(app.tr("prefs.line_hover_tangent_arc_note")); + crate::scenario::publish_target("prefs:line_hover_tangent_arc", line_arc.rect); ui_kit::settings_block_hint(ui, app.tr("prefs.hold_stop_snapping"), app.tr("prefs.snap_drawing_note"), t.space_24 * 8.0 + t.space_8, |ui| { let combo = &mut app.preferences.suppress_sketch_snap; ui_kit::settings_control_width(ui, t.space_24 * 7.0 + t.space_12, |ui| { diff --git a/crates/ui-egui/src/prefs/tools.rs b/crates/ui-egui/src/prefs/tools.rs new file mode 100644 index 0000000000..0ba3a8f38f --- /dev/null +++ b/crates/ui-egui/src/prefs/tools.rs @@ -0,0 +1,58 @@ +//! Existing modelling-tool controls, regrouped without changing their semantics. +use super::*; + +pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) { + let t = Tokens::get(); + ui_kit::settings_heading(ui, app.tr("prefs.structure.tools"), app.tr("prefs.structure.tools_note")); + // The Hole command's own settings, under its name. + ui_kit::settings_section(ui, "create_hole", crate::i18n::tr_label("Create Hole"), |ui| { + setting::hole_snaps(app, ui); + ui_kit::text( + ui, + crate::i18n::tr_label("A Single hole snaps to the middle of its face and to the centres of its round edges"), + ui_kit::Text::Hint("hole_snaps"), + ); + setting::hole_moves(app, ui); + ui_kit::text( + ui, + crate::i18n::tr_label( + "A placed Single hole has a handle to drag it, and picking its face again moves the hole there instead of taking it out", + ), + ui_kit::Text::Hint("hole_moves"), + ); + }); + ui_kit::settings_section(ui, "extrude", app.tr_label("Extrude"), |ui| { + setting::extrude_auto_to_object(app, ui); + // Extrude's view (#9176): isometric when it opens, and back afterwards only with that. + setting::extrude_view_isometric(app, ui); + let isometric = app.preferences.extrude_view_isometric; + ui.add_enabled_ui(isometric, |ui| setting::extrude_view_return(app, ui)); + let auto = setting::auto_operation(app, ui); + crate::scenario::publish_handle("prefs:auto_operation", auto.rect.center()); + ui_kit::text(ui, app.tr("prefs.auto_operation_note"), ui_kit::Text::Hint("auto_operation")); + }); + ui_kit::settings_section(ui, "placement", app.tr("prefs.placement"), |ui| { + setting::placement_handles(app, ui); + }); + ui_kit::settings_section(ui, "joint_move", app.tr("joint_move.settings"), |ui| { + setting::assembly_move_handles(app, ui); + ui_kit::text(ui, app.tr("joint_move.legacy_hint"), ui_kit::Text::Hint("joint_move_legacy")); + ui_kit::settings_form(ui, t.space_24 * 7.0 + t.space_2, |ui| { + ui_kit::row(ui, app.tr("joint_move.coarse"), |ui| { + ui_kit::settings_number(ui, &mut app.preferences.joint_rotation_coarse, 0.001..=180.0, 0.1); + }); + ui_kit::row(ui, app.tr("joint_move.fine"), |ui| { + ui_kit::settings_number(ui, &mut app.preferences.joint_rotation_fine, 0.001..=180.0, 0.01); + }); + }); + ui_kit::text(ui, app.tr("joint_move.precision"), ui_kit::Text::Hint("joint_move")); + }); + ui_kit::settings_section(ui, "measure_pointer", app.tr("prefs.structure.measure_pointer"), |ui| { + let response = setting::measure_axis_distances(app, ui); + crate::scenario::publish_rect("prefs:measure_axis_distances", response.rect); + response.on_hover_text(app.tr("settings.measure_axis_distances_help")); + let cursor = ui_kit::checkbox(ui, crate::i18n::tr_label("Show tool at cursor"), &mut app.ui.tool_cursor); + crate::scenario::publish_target("view:tool_cursor", cursor.rect); + setting::direct_interaction(app, ui); + }); +} diff --git a/crates/ui-egui/src/prefs_search.rs b/crates/ui-egui/src/prefs_search.rs index ad9514bf44..72796dd1b6 100644 --- a/crates/ui-egui/src/prefs_search.rs +++ b/crates/ui-egui/src/prefs_search.rs @@ -783,6 +783,13 @@ mod tests { h.step(&serde_json::json!({"key": "ArrowUp"})).expect("previous match"); h.frames(1); assert_eq!(crate::scenario::handle_at("prefs:search:current"), first, "Up moves back"); + let (_, _, hits) = nav(&h.ctx); + let cursor = hits + .iter() + .position(|hit| hit.page == crate::prefs::PAGE_NAVIGATION && hit.label == "Reverse zoom direction") + .expect("the Navigation setting remains keyboard reachable"); + set_cursor(&h.ctx, cursor); + h.frames(2); h.step(&serde_json::json!({"key": "Enter"})).expect("open"); h.frames(2); assert_eq!(h.app.prefs_window.section, crate::prefs::PAGE_NAVIGATION, "Enter opens the match's page"); @@ -905,7 +912,7 @@ mod tests { fn preferences_search_finds_settings_in_rows_without_a_label() { let mut h = crate::scenario::Harness::new(); h.app.home.open = false; - crate::prefs::open_at(&mut h.app, crate::prefs::PAGE_INTERFACE); + crate::prefs::open_at(&mut h.app, crate::prefs::PAGE_LAYOUT); h.frames(2); // Drawn, not merely present: a hidden row's controls still exist, far off screen. let drawn = |h: &crate::scenario::Harness, label: &str| { @@ -939,4 +946,29 @@ mod tests { h.frames(4); assert!(!drawn(&h, "Status bar"), "a miss shows nothing"); } + #[test] + fn moved_preferences_search_paths_and_enter_follow_window_groups() { + for (query, path, page) in [ + ("Direct interaction", "Work > Tools", crate::prefs::PAGE_TOOLS), + ("Navigate with the 3D mouse", "Input > 3D mouse", crate::prefs::PAGE_MOUSE3D), + ("Origin plane names", "Work > Design", crate::prefs::PAGE_DESIGN), + ("Status bar", "Look > Layout", crate::prefs::PAGE_LAYOUT), + ] { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + crate::prefs::open_at(&mut h.app, crate::prefs::PAGE_GENERAL); + h.app.prefs_window.filter = query.into(); + h.frames(4); + assert!(h.widget(path).is_some(), "{query} shows {path}"); + let (_, _, hits) = nav(&h.ctx); + let index = hits.iter().position(|hit| hit.page == page && hit.label == query).expect("moved control is keyboard reachable"); + set_cursor(&h.ctx, index); + h.frames(2); + h.step(&serde_json::json!({"key":"Enter"})).unwrap(); + h.frames(2); + assert_eq!(h.app.prefs_window.section, page); + assert!(h.app.prefs_window.filter.is_empty()); + assert!(h.widget(query).is_some()); + } + } } diff --git a/crates/ui-egui/src/preselect_tests.rs b/crates/ui-egui/src/preselect_tests.rs index 77b4bfb06e..df85f2323e 100644 --- a/crates/ui-egui/src/preselect_tests.rs +++ b/crates/ui-egui/src/preselect_tests.rs @@ -1911,7 +1911,7 @@ fn align_components_input_picks_occurrence_from_viewport_body_and_skips_root_bod let second = app.run("occurrence.copy", json!({"component": comp, "translate": [30, 0, 0]})).unwrap()["occurrence"].as_u64().unwrap(); let mut d = start(&mut app, vec![], "solid.align"); - d.kind = Kind::Align { flip: false, components: true }; + d.kind = Kind::Align { flip: false, components: true, axes: [true; 3] }; d.inputs[0].label = "Components"; d.active = 0; @@ -2724,8 +2724,10 @@ fn convert_mesh_asks_for_the_body_and_converts_the_selected_one() { /// #3745: hovering a large body never waits for its pick index. A long modelled thread's mesh /// has a quarter of a million triangles and its index took a third of a second to make, on the /// first hover, in the frame. Now the index is made on a worker and the first hovers scan the -/// mesh: the first hover takes a small part of what making the index takes, and still finds -/// the face under the pointer. +/// mesh. Checked without timing: the worker is held at a gate, so the index is certainly still +/// being made during the hover, and the hover must still find the face under the pointer. Were +/// the hover to build the index itself it would not be pending, and a hover that waited on the +/// worker would never return while the gate is closed. #[test] fn hovering_a_large_body_does_not_wait_for_its_pick_index() { // A closed ball of 300 000 triangles, inserted as a mesh body. @@ -2763,19 +2765,21 @@ fn hovering_a_large_body_does_not_wait_for_its_pick_index() { h.app.run("file.insert_mesh", json!({"path": path.to_string_lossy()})).unwrap(); let mesh = h.app.session.world_state().bodies[0].mesh(); assert!(mesh.triangles.len() > 250_000, "{}", mesh.triangles.len()); - let t = std::time::Instant::now(); - let _ = solidcraft_engine::geom::MeshIndex::new(&mesh); - let index_ms = t.elapsed().as_secs_f64() * 1000.0; + let gate = h.app.viewport.pick_index.gate(); + gate.set_closed(true); h.app.fit_view(); h.frames(3); let at = h.at(&json!({"world": [0.0, -20.0, 0.0]})).unwrap(); - let t = std::time::Instant::now(); h.step(&json!({"move": at})).unwrap(); - let hover_ms = t.elapsed().as_secs_f64() * 1000.0; assert!(matches!(h.app.viewport.hover, Some(crate::viewport::Hit::Face { .. })), "{:?}", h.app.viewport.hover); - // Half, not a third: on a loaded runner the hover alone has taken 70 ms against a 190 ms index. - // Waiting for the index would cost the whole of it, so half still tells the two apart. - assert!(hover_ms < index_ms / 2.0, "the first hover took {hover_ms:.1} ms; making the index takes {index_ms:.1} ms"); + assert_eq!(h.app.viewport.pick_index.pending(), 1, "the hover should have left the index to the worker"); + // Let the worker finish so it does not outlive the test holding the mesh. + gate.set_closed(false); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(120); + while h.app.viewport.pick_index.pending() > 0 && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert_eq!(h.app.viewport.pick_index.pending(), 0, "the index should be made once the worker is let go"); } /// #4452: with Combine open, bodies clicked in the Browser fill its inputs, as clicks on them diff --git a/crates/ui-egui/src/recent_colors.rs b/crates/ui-egui/src/recent_colors.rs new file mode 100644 index 0000000000..f77376b8c9 --- /dev/null +++ b/crates/ui-egui/src/recent_colors.rs @@ -0,0 +1,55 @@ +//! The colours picked last in any colour field, newest first (#9234). The app keeps the list in +//! its saved settings (`UiState::recent_colors`) and hands it to the colour pickers every frame +//! through egui memory; the shared `ui_kit` colour popover shows it and adds finished picks. + +/// How many colours the recent list keeps. +pub(crate) const MAX_RECENT_COLORS: usize = 12; + +/// The colours picked last, newest first, shared by every colour picker of the window. +#[derive(Clone, Default)] +struct RecentColors { + colors: Vec<[u8; 3]>, + /// Picked since the app last took the list for its settings. + changed: bool, +} + +fn recent_id() -> egui::Id { + egui::Id::new("color_recent_colors") +} + +/// The recently picked colours, newest first. +pub(crate) fn recent_colors(ctx: &egui::Context) -> Vec<[u8; 3]> { + ctx.data(|d| d.get_temp::(recent_id())).map(|r| r.colors).unwrap_or_default() +} + +/// Put a finished pick at the front of the recent list (once, capped). +/// `replacing` is the colour the same popover added earlier while it stayed open: it goes if it +/// is still the newest entry. +pub(crate) fn remember_color(ctx: &egui::Context, rgb: [u8; 3], replacing: Option<[u8; 3]>) { + ctx.data_mut(|d| { + let recent = d.get_temp_mut_or_default::(recent_id()); + if replacing.is_some() && recent.colors.first().copied() == replacing { + recent.colors.remove(0); + } + recent.colors.retain(|c| *c != rgb); + recent.colors.insert(0, rgb); + recent.colors.truncate(MAX_RECENT_COLORS); + recent.changed = true; + }); +} + +/// Keep the saved list and the pickers' list the same; the app calls this every frame. Picks made +/// since the last call are written to `saved`; otherwise `saved` (loaded, reset) is what the +/// pickers show. +pub(crate) fn share(ctx: &egui::Context, saved: &mut Vec<[u8; 3]>) { + saved.truncate(MAX_RECENT_COLORS); + ctx.data_mut(|d| { + let recent = d.get_temp_mut_or_default::(recent_id()); + if recent.changed { + saved.clone_from(&recent.colors); + recent.changed = false; + } else if recent.colors != *saved { + recent.colors.clone_from(saved); + } + }); +} diff --git a/crates/ui-egui/src/render_workspace.rs b/crates/ui-egui/src/render_workspace.rs index b09ab22aff..c6ffcbeb76 100644 --- a/crates/ui-egui/src/render_workspace.rs +++ b/crates/ui-egui/src/render_workspace.rs @@ -3,7 +3,7 @@ use std::hash::{Hash, Hasher}; use std::sync::{Arc, Mutex}; -use egui::{Color32, Sense}; +use egui::Sense; use serde_json::json; use solidcraft_engine::doc::render_settings::RenderSetup; #[cfg(test)] @@ -480,8 +480,9 @@ impl Default for State { impl State { /// Retain the selected viewport adapter, but do not open another device or - /// compile shaders until Render is used. A later viewport installation (for - /// example safe-mode fallback) replaces only a not-yet-started factory. + /// compile shaders until the GPU path image method is used in Render + /// ([`wants_path_host`]). A later viewport installation (for example safe-mode + /// fallback) replaces only a not-yet-started factory. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn defer_path_host(&mut self, key: String, create: impl FnOnce() -> Result + Send + 'static) { if matches!(self.path_host, PathHost::NotStarted) { @@ -556,9 +557,17 @@ pub fn host_executor(max_dimension: u32, depth: bool, samples: u32) -> Result bool { + active(app) && app.render_workspace.view.method == ImageMethod::GpuRaytrace +} + pub fn sync_backend(app: &mut SolveApp) { #[cfg(not(target_arch = "wasm32"))] - if active(app) { + if wants_path_host(app) { app.render_workspace.start_deferred_path_host(); } if let Some(took) = app.render_workspace.path_host.poll() { @@ -673,7 +682,9 @@ pub fn enter(app: &mut SolveApp, render: bool) -> Result<(), String> { } } #[cfg(not(target_arch = "wasm32"))] - app.render_workspace.start_deferred_path_host(); + if wants_path_host(app) { + app.render_workspace.start_deferred_path_host(); + } } else { assign::stop(app); if let Some((camera, perspective)) = app.render_workspace.design_camera.take() { @@ -1933,6 +1944,27 @@ pub fn preview_size(executor: Option<&ExecutorDescriptor>, full: (u64, u64)) -> executor.and_then(|e| e.fit(full.0, full.1)).unwrap_or(full) } +/// Theme only the untouched, unsaved preview. Saved setups have no background provenance, +/// so even a saved default-colour backplate remains scene-owned. Never alter image requests. +fn preview_background(app: &SolveApp) -> Tokens { + let view = &app.render_workspace.view; + let tokens = Tokens::get(); + if crate::theme::tab_surfaces_enabled() + && !app.session.doc.render.setups.contains_key(&view.name) + && view.clean_setup.as_ref().is_some_and(|clean| clean.background == view.draft.background) + && !view.transparent + && !view.studio.backdrop_enabled + && view.studio.environment == studio::Environment::None + && view.cpu.environment == cpu::Environment::None + && view.raytrace.environment.is_none() + { + // Includes the current fade and explicit viewport palette/flat-colour preferences. + tokens + } else { + tokens.with_background(Some(view.draft.background)) + } +} + pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { if app.render_workspace.view.result { app.render_workspace.view.environment_ring = environment::RingView::default(); @@ -1987,8 +2019,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { } else { app.viewport.hover = None; } - let bg = app.render_workspace.view.draft.background; - ui.painter().rect_filled(viewport, 0.0, Color32::from_rgb(bg[0], bg[1], bg[2])); + crate::gpu::paint_background(ui.painter(), viewport, &preview_background(app)); let result = (|| -> Result<(), String> { if app.viewport.gpu.is_none() { return Err(crate::i18n::tr("render.error_gpu_preview").into()); @@ -2084,6 +2115,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { // Product rendering has its own lighting; AO is a Design viewport setting. ao_radius: 0.0, ao_samples: solidcraft_gpu::view::AO_SAMPLES, + sky: [0.0; 4], eye: [eye.x as f32, eye.y as f32, eye.z as f32, if camera.fov > 0.0 { 1.0 } else { 0.0 }], ssaa: if width < full.0 { width as f32 / full.0 as f32 } else { 1.0 }, hover_tint: [f32::from(Tokens::get().hover_face_lift) / 255.0; 3], diff --git a/crates/ui-egui/src/render_workspace_tests.rs b/crates/ui-egui/src/render_workspace_tests.rs index 8108bab2b4..ea6a4f01ed 100644 --- a/crates/ui-egui/src/render_workspace_tests.rs +++ b/crates/ui-egui/src/render_workspace_tests.rs @@ -1,5 +1,6 @@ use super::*; use crate::scenario::Harness; +use egui::Color32; use solidcraft_engine::Session; fn environment_editor_frame(h: &mut Harness, events: Vec) { @@ -867,6 +868,52 @@ mod async_path_host { h.frames(3); } + /// Choose the GPU path image method, which is what starts the deferred setup. + fn choose_gpu_path(h: &mut Harness) { + select_image_method(&mut h.app, ImageMethod::GpuRaytrace); + h.frames(1); + } + + /// Entering Render and moving its view with the Raster (or CPU) image method never compiles + /// the GPU path tracer: on Vulkan drivers that compile takes gigabytes (#9041). Choosing the + /// GPU path method starts it, once. + #[test] + fn render_with_another_image_method_never_starts_the_path_setup() { + use std::sync::atomic::{AtomicUsize, Ordering}; + let calls = Arc::new(AtomicUsize::new(0)); + let observed = calls.clone(); + let mut h = Harness::new(); + h.call("ui.resize", json!({"width":1200,"height":900})); + h.app.render_workspace.defer_path_host("selected viewport".into(), move || { + observed.fetch_add(1, Ordering::SeqCst); + job::ImageHost::with_executor(Arc::new(TestExecutor)).map_err(|e| e.to_string()) + }); + enter(&mut h.app, true).unwrap(); + assert_eq!(h.app.render_workspace.view.method, ImageMethod::Raster); + for step in 0..20 { + h.call("ui.camera", json!({"yaw": f64::from(step) * 0.1, "pitch": 0.3})); + h.frames(1); + } + select_image_method(&mut h.app, ImageMethod::CpuRaytrace); + h.frames(5); + enter(&mut h.app, false).unwrap(); + enter(&mut h.app, true).unwrap(); + h.frames(5); + assert_eq!(calls.load(Ordering::SeqCst), 0); + assert!(matches!(h.app.render_workspace.path_host, PathHost::NotStarted)); + assert!(h.app.render_workspace.pending_setup.is_some()); + choose_gpu_path(&mut h); + finish_setup(&mut h); + assert_eq!(calls.load(Ordering::SeqCst), 1); + assert!(matches!(h.app.render_workspace.path_host, PathHost::Ready(_))); + // The GPU method chosen in Design (a loaded setup) waits for Render. + let mut h = Harness::new(); + h.app.render_workspace.defer_path_host("selected viewport".into(), || Err("not opened".into())); + select_image_method(&mut h.app, ImageMethod::GpuRaytrace); + h.frames(5); + assert!(matches!(h.app.render_workspace.path_host, PathHost::NotStarted)); + } + #[test] fn design_frames_and_document_reset_never_start_the_deferred_path_host() { use std::sync::atomic::{AtomicUsize, Ordering}; @@ -884,6 +931,7 @@ mod async_path_host { assert!(matches!(h.app.render_workspace.path_host, PathHost::NotStarted)); assert!(h.app.render_workspace.pending_setup.is_some()); enter(&mut h.app, true).unwrap(); + choose_gpu_path(&mut h); finish_setup(&mut h); assert_eq!(calls.load(Ordering::SeqCst), 1); assert!(matches!(h.app.render_workspace.path_host, PathHost::Ready(_))); @@ -905,6 +953,8 @@ mod async_path_host { Err("deliberate device refusal".into()) }); sync_backend(&mut h.app); + assert!(matches!(h.app.render_workspace.path_host, PathHost::NotStarted)); + choose_gpu_path(&mut h); finish_setup(&mut h); assert_eq!(calls.load(Ordering::SeqCst), 1); assert!(matches!(h.app.render_workspace.path_host, PathHost::Failed(_))); @@ -923,6 +973,7 @@ mod async_path_host { h.frames(5); assert_eq!(h.app.render_workspace.setup_key.as_deref(), Some("selected safe-mode adapter")); enter(&mut h.app, true).unwrap(); + choose_gpu_path(&mut h); finish_setup(&mut h); let PathHost::Failed(message) = &h.app.render_workspace.path_host else { panic!("expected selected adapter result") }; assert_eq!(message, "selected adapter unavailable"); @@ -1299,6 +1350,150 @@ fn saved_camera_survives_frames_and_product_fit_ignores_selection() { assert_eq!(h.app.cam, design); } +/// Inspect the mesh submitted by the actual Render canvas, including its GPU-unavailable path. +/// This is a headless colour handoff assertion, not a rendered-pixel or screenshot comparison. +fn render_preview_background_frame(h: &mut Harness, time: f64) -> [Color32; 4] { + let app = &mut h.app; + let mut viewport = egui::Rect::NOTHING; + let mut output = h.ctx.run_ui( + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))), + time: Some(time), + ..Default::default() + }, + |ui| { + app.logic(ui.ctx()); + viewport = ui.max_rect(); + show(app, ui); + }, + ); + let corners = [viewport.left_top(), viewport.right_top(), viewport.right_bottom(), viewport.left_bottom()]; + let colors = output + .shapes + .iter() + .find_map(|shape| match &shape.shape { + egui::Shape::Mesh(mesh) if mesh.vertices.len() == 4 && mesh.vertices.iter().zip(corners).all(|(vertex, corner)| vertex.pos == corner) => { + Some(std::array::from_fn(|i| mesh.vertices[i].color)) + } + _ => None, + }) + .expect("Render must paint a background mesh over the full viewport"); + output.textures_delta.clear(); + colors +} + +#[test] +fn render_preview_tab_surfaces_use_published_gradient_through_fade() { + for base in ["dark", "light"] { + let mut h = Harness::new(); + let file = crate::theme_files::tab_fixture(base); + crate::prefs::control(&mut h.app, &json!({"theme_import":crate::theme_files::export_file(&file)})).unwrap(); + let app = &mut h.app; + h.ctx.run_ui(egui::RawInput { time: Some(1.0), ..Default::default() }, |ui| app.logic(ui.ctx())).textures_delta.clear(); + let model = Tokens::get(); + enter(&mut h.app, true).unwrap(); + assert!(h.app.session.doc.render.setups.is_empty()); + let document = h.app.session.doc.clone(); + let request = image_request(&h.app).unwrap(); + let history = (h.app.session.revision, h.app.session.undo.len()); + let end = crate::theme_files::resolve_for_tab(&file, &Default::default(), Some("RENDER")); + let expected_start = [model.viewport_top, model.viewport_top, model.viewport_bottom, model.viewport_bottom]; + let expected_end = [end.viewport_top, end.viewport_top, end.viewport_bottom, end.viewport_bottom]; + let mut previous = expected_start; + for progress in [0.0, 0.25, 0.5, 0.75, 1.0] { + let colors = render_preview_background_frame(&mut h, 2.0 + 0.24 * progress); + let shown = Tokens::get(); + assert_eq!(colors, [shown.viewport_top, shown.viewport_top, shown.viewport_bottom, shown.viewport_bottom]); + if progress == 0.0 { + assert_eq!(colors, expected_start); + } else if progress == 1.0 { + assert_eq!(colors, expected_end); + } else if progress <= 0.5 { + assert_ne!(colors, expected_start, "the Render canvas must leave the starting colours at {progress}"); + assert_ne!(colors, expected_end, "the Render canvas must receive intermediate fade colours at {progress}"); + assert_ne!(colors, previous, "the Render canvas fade must advance between early samples at {progress}"); + } + // At 75% elapsed time, ease-out cubic is already 98.4375% of the way to the + // destination. Byte quantization can legitimately produce the exact final colours. + previous = colors; + assert!(Arc::ptr_eq(&document, &h.app.session.doc)); + assert_eq!((h.app.session.revision, h.app.session.undo.len()), history); + assert_eq!(image_request(&h.app).unwrap(), request, "UI themes never modify rendered image settings"); + } + assert_eq!(render_preview_background_frame(&mut h, 3.0), expected_end); + } +} + +#[test] +fn render_preview_tab_surfaces_preserve_scene_backplates_and_viewport_overrides() { + for base in ["dark", "light"] { + let mut h = Harness::new(); + let file = crate::theme_files::tab_fixture(base); + crate::prefs::control( + &mut h.app, + &json!({ + "theme_import":crate::theme_files::export_file(&file), + "theme_options":{&file.id:{"tab_fade_ms":0}} + }), + ) + .unwrap(); + enter(&mut h.app, true).unwrap(); + let scene_rgb = h.app.render_workspace.view.draft.background; + let scene = [Color32::from_rgb(scene_rgb[0], scene_rgb[1], scene_rgb[2]); 4]; + let gradient = render_preview_background_frame(&mut h, 1.0); + assert_ne!(gradient, scene); + + crate::prefs::control(&mut h.app, &json!({"theme_options":{&file.id:{"tab_colours":false}}})).unwrap(); + assert_eq!(render_preview_background_frame(&mut h, 2.0), scene); + crate::prefs::control( + &mut h.app, + &json!({"theme_options":{&file.id:{"tab_colours":true}},"viewport_theme":if base == "dark" {"light"} else {"dark"}}), + ) + .unwrap(); + let opposite = if base == "dark" { Tokens::light() } else { Tokens::dark() }; + assert_eq!( + render_preview_background_frame(&mut h, 3.0), + [opposite.viewport_top, opposite.viewport_top, opposite.viewport_bottom, opposite.viewport_bottom] + ); + crate::prefs::control(&mut h.app, &json!({"viewport_theme":"theme","viewport_background":[3,7,11]})).unwrap(); + assert_eq!(render_preview_background_frame(&mut h, 4.0), [Color32::from_rgb(3, 7, 11); 4]); + + // Authored scene intent takes priority even over a viewport preference. Test temporary + // drafts as well as saved setups, without persisting any theme colour into the scene. + let original = h.app.render_workspace.view.clone(); + for case in 0..6 { + h.app.render_workspace.view = original.clone(); + match case { + 0 => h.app.render_workspace.view.draft.background = [21, 43, 65], + 1 => h.app.render_workspace.view.transparent = true, + 2 => h.app.render_workspace.view.studio.backdrop_enabled = true, + 3 => h.app.render_workspace.view.studio.environment = studio::Environment::Sky, + 4 => h.app.render_workspace.view.cpu.environment = cpu::Environment::Constant, + _ => { + h.app.render_workspace.view.raytrace.environment = + Some(solidcraft_engine::render::job::RaytraceEnvironmentOptions { color: [0.2, 0.3, 0.4], intensity: 1.0 }) + } + } + let expected = if case == 0 { [Color32::from_rgb(21, 43, 65); 4] } else { scene }; + assert_eq!(render_preview_background_frame(&mut h, 5.0 + f64::from(case)), expected); + } + h.app.render_workspace.view = original; + h.app.run("render.settings.set", json!({"name":"Studio","set":{"camera":h.app.cam}})).unwrap(); + setups::load(&mut h.app); + let authored = h.app.session.doc.clone(); + assert_eq!(render_preview_background_frame(&mut h, 11.0), scene, "even a saved default-colour backplate is scene-owned"); + assert!(Arc::ptr_eq(&authored, &h.app.session.doc)); + + // A theme without tabs keeps the legacy Render background, as do built-in themes. + h.app.render_workspace.view.name = "Unsaved".into(); + setups::load(&mut h.app); + crate::prefs::control(&mut h.app, &json!({"theme_import":{"format":"solidcraft-theme/1","name":"No tabs","base":base}})).unwrap(); + assert_eq!(render_preview_background_frame(&mut h, 12.0), scene); + crate::prefs::control(&mut h.app, &json!({"theme":base})).unwrap(); + assert_eq!(render_preview_background_frame(&mut h, 13.0), scene); + } +} + #[cfg(not(target_arch = "wasm32"))] #[test] fn progressive_image_controls_route_to_the_registered_host() { diff --git a/crates/ui-egui/src/resume_ui.rs b/crates/ui-egui/src/resume_ui.rs new file mode 100644 index 0000000000..47927df283 --- /dev/null +++ b/crates/ui-egui/src/resume_ui.rs @@ -0,0 +1,70 @@ +//! Recover the egui chrome after the display wakes from sleep (#8749). +//! +//! On Linux (Wayland + NVIDIA especially) the window can stay black after wake: the device is not +//! reported lost, so SolidCraft does not restart, but GPU-backed egui textures (font atlas, icons) +//! are stale. The viewport often comes back on scroll or a tab change because it rebuilds its own +//! meshes; the ribbon and panels stay blank until those textures are uploaded again. +//! +//! When egui's frame clock jumps by more than [`LONG_PAUSE`], treat that as a wake and refresh. + +use std::sync::Arc; + +use egui::{ImageData, TextureId, TextureOptions}; + +/// A gap this long between frames means the process was not painting (sleep, lock screen, …). +pub const LONG_PAUSE: f64 = 1.5; + +/// Re-upload the font atlas, drop cached icon GPU textures, and ask for another frame. +pub fn after_long_pause(ctx: &egui::Context) { + refresh_font_atlas(ctx); + crate::icons::clear_texture_cache(ctx); + ctx.forget_all_images(); + ctx.request_repaint(); +} + +/// Push the full font atlas so the backend replaces TextureId::default on the GPU. +fn refresh_font_atlas(ctx: &egui::Context) { + let image = ctx.fonts(|fonts| fonts.image()); + let delta = egui::epaint::ImageDelta::full(ImageData::Color(Arc::new(image)), TextureOptions::default()); + ctx.tex_manager().write().set(TextureId::default(), delta); +} + +/// True when `now` is more than [`LONG_PAUSE`] after `previous` (and both are finite and ordered). +pub fn is_long_pause(previous: f64, now: f64) -> bool { + previous.is_finite() && now.is_finite() && previous > 0.0 && now > previous && (now - previous) > LONG_PAUSE +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn long_pause_is_a_jump_in_frame_time() { + assert!(!is_long_pause(0.0, 10.0), "first frame has no previous"); + assert!(!is_long_pause(1.0, 1.2)); + assert!(is_long_pause(1.0, 1.0 + LONG_PAUSE + 0.01)); + assert!(!is_long_pause(f64::NAN, 10.0)); + } + + #[test] + fn after_long_pause_queues_a_full_font_atlas_upload() { + let ctx = egui::Context::default(); + crate::fonts::setup_bundled_fonts(&ctx); + // Build the atlas once so TextureId::default exists. + let mut primed = ctx.run_ui(egui::RawInput::default(), |ui| { + ui.label("wake"); + }); + primed.textures_delta.clear(); + after_long_pause(&ctx); + // The refresh is applied as a tex_manager delta taken at the start of the next pass. + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + ui.label("again"); + }); + let font = out.textures_delta.set.get(&TextureId::default()); + assert!( + font.is_some_and(|deltas| deltas.iter().any(|d| d.pos.is_none() && matches!(&d.image, ImageData::Color(img) if !img.pixels.is_empty()))), + "full font atlas delta with pixels" + ); + out.textures_delta.clear(); + } +} diff --git a/crates/ui-egui/src/revolve_edit_tests.rs b/crates/ui-egui/src/revolve_edit_tests.rs index 4f34753a61..995ce6f5a3 100644 --- a/crates/ui-egui/src/revolve_edit_tests.rs +++ b/crates/ui-egui/src/revolve_edit_tests.rs @@ -274,7 +274,7 @@ fn only_the_active_selection_field_looks_active() { let filled = border(&h, "Profile"); click(&mut h, "dialog:Angle"); assert_eq!(h.app.dialog.as_ref().unwrap().active, 1, "picks still go to Axis while Angle is typed into"); - assert_eq!(border(&h, "Axis"), t.form_primary); + assert_ne!(border(&h, "Axis"), t.form_primary, "only the field with the keyboard looks active (#10066)"); click(&mut h, "dialog:clear:Axis"); click(&mut h, "dialog:field:Profile"); assert_eq!(crate::scenario::count_of("dialog:selection:Axis:filled"), Some(0.0)); diff --git a/crates/ui-egui/src/scale_tests.rs b/crates/ui-egui/src/scale_tests.rs index c4afcbc250..703da2caf3 100644 --- a/crates/ui-egui/src/scale_tests.rs +++ b/crates/ui-egui/src/scale_tests.rs @@ -291,6 +291,7 @@ pub(crate) fn view_frame( eye: [e[0], e[1], e[2], if proj.cam.fov > 0.0 { 1.0 } else { 0.0 }], ao_radius: 0.0, ao_samples: solidcraft_gpu::view::AO_SAMPLES, + sky: [0.0; 4], ssaa: 1.0, hover_tint: [f32::from(t.hover_face_lift) / 255.0; 3], selection_tint: [0.4, 0.6, 0.9], @@ -407,6 +408,39 @@ fn frame_cost_of_a_design_file() { } } +/// A design from a file (`SOLIDCRAFT_SCALE_DESIGN`) open in two tabs, both with an unsaved change +/// the comparison with the saved design finds only at its end (a favourite, as a changed +/// appearance is): the time of an idle frame, and of one unsaved-changes check +/// (`Session::is_dirty`, which the tab strip and the autosave ask every frame). +#[test] +#[ignore = "manual measurement on a design file"] +fn idle_frame_cost_of_two_unsaved_tabs() { + let Some(path) = std::env::var_os("SOLIDCRAFT_SCALE_DESIGN") else { return }; + let path = path.to_string_lossy().to_string(); + let open = || { + let (doc, saved) = solidcraft_engine::io::read_design_file(&path, None::).unwrap(); + let mut s = solidcraft_engine::Session::opened(doc, saved.as_ref()); + s.doc_mut().favorites.insert("idle-measurement".into()); + s.revision += 1; + s + }; + let mut h = Harness::new(); + h.app.session = open(); + crate::documents::open_tab(&mut h.app, open()).unwrap(); + assert!(h.app.session.is_dirty()); + h.frames(5); + let median = |mut v: Vec| { + v.sort_by(f64::total_cmp); + (v[v.len() / 2], v[0], v[v.len() - 1]) + }; + let (frame, frame_min, frame_max) = median((0..30).map(|_| ms(|| h.frame())).collect()); + let (dirty, dirty_min, dirty_max) = median((0..30).map(|_| ms(|| assert!(std::hint::black_box(h.app.session.is_dirty())))).collect()); + let bodies = h.app.session.model.state().bodies.len(); + println!( + "IDLE2TABS bodies={bodies} idle_frame_ms={frame:.3} ({frame_min:.3}-{frame_max:.3}) is_dirty_ms={dirty:.4} ({dirty_min:.4}-{dirty_max:.4})" + ); +} + /// A whole assembly's hover highlight: the model's own triangles (its display plans on the GPU) /// against the copy of every triangle it used to build here. Run with `--ignored --nocapture`. #[test] diff --git a/crates/ui-egui/src/scenario.rs b/crates/ui-egui/src/scenario.rs index ada0605f4e..757141daff 100644 --- a/crates/ui-egui/src/scenario.rs +++ b/crates/ui-egui/src/scenario.rs @@ -8,7 +8,8 @@ //! text (with `near`: the one in that label's row) //! - `{"click": AT, "double"?, "shift"?, "ctrl"?, "button"?}`, `{"move": AT, "shift"?, "alt"?, "ctrl"?, "cmd"?}`, //! `{"hold": AT, "frames"?}` (press and hold still: Select Other), -//! `{"drag": [AT, AT], "shift"?, "ctrl"?, "steps"?}` where AT is `[x, y]` (screen), `{"world": [x,y,z]}` +//! `{"drag": [AT, AT, ...], "shift"?, "ctrl"?, "steps"?}` (through any points between, `steps` moves +//! each) where AT is `[x, y]` (screen), `{"world": [x,y,z]}` //! or `{"sketch": [x,y]}` (the sketch being edited) //! - `{"queued": "engine.execute", "params": {…}, "min_frames"?: n, "max_frames"?: n}`: a request sent the way an //! agent's arrive (through the app's request queue, answered between frames, so the agent @@ -449,6 +450,19 @@ impl Harness { Err(format!("view_anim {v}: still animating after 240 frames")) } + /// A drag step's first and last points, and the screen points it passes through between them + /// (a lasso's loop, #4376). + fn drag_points(&mut self, d: &Value) -> Result<([f64; 2], [f64; 2], Vec), String> { + let pts = d.as_array().map(Vec::as_slice).unwrap_or_default(); + let start = self.at(pts.first().unwrap_or(&Value::Null))?; + let end = self.at(pts.get(pts.len().saturating_sub(1).max(1)).unwrap_or(&Value::Null))?; + let mut via = Vec::new(); + for q in pts.iter().skip(1).take(pts.len().saturating_sub(2)) { + via.push(json!(self.at(q)?)); + } + Ok((start, end, via)) + } + /// Run one step. pub fn step(&mut self, s: &Value) -> Result<(), String> { let flag = |k: &str| s.get(k).and_then(Value::as_bool).unwrap_or(false); @@ -504,13 +518,12 @@ impl Harness { let [x, y] = self.at(at)?; self.call("ui.move", json!({"x": x, "y": y,"shift":flag("shift"),"alt":flag("alt"),"ctrl":flag("ctrl"),"cmd":flag("cmd")})) } else if let Some(d) = s.get("drag") { - let [x0, y0] = self.at(d.get(0).unwrap_or(&Value::Null))?; - let [x1, y1] = self.at(d.get(1).unwrap_or(&Value::Null))?; + let ([x0, y0], [x1, y1], via) = self.drag_points(d)?; let steps = s.get("steps").cloned().unwrap_or(json!(8)); - self.call( - "ui.drag", - json!({"x0": x0, "y0": y0, "x1": x1, "y1": y1, "steps": steps, "shift": flag("shift"), "ctrl": flag("ctrl"), "alt": flag("alt")}), - ) + let mut p = + json!({"x0": x0, "y0": y0, "x1": x1, "y1": y1, "steps": steps, "shift": flag("shift"), "ctrl": flag("ctrl"), "alt": flag("alt")}); + p["via"] = Value::Array(via); + self.call("ui.drag", p) } else if let Some(m) = s.get("queued").and_then(Value::as_str) { let (v, frames) = self.call_queued(m, s.get("params").cloned().unwrap_or(json!({}))); let min = s.get("min_frames").and_then(Value::as_u64).unwrap_or(0); @@ -1098,6 +1111,16 @@ pub fn check(h: &mut Harness, e: &Value) -> Result<(), String> { } } } + "theme_colors" => { + // Read the applied tokens after the scenario frame, not the source theme file. + let tokens = crate::theme::Tokens::get(); + let resolved = crate::theme_files::export("Scenario", crate::theme_files::Base::Dark, &tokens); + for (key, want) in v.as_object().into_iter().flatten() { + if resolved["colors"][key] != *want { + return Err(format!("theme colour {key}: got {}, want {want}", resolved["colors"][key])); + } + } + } "file_busy" | "quit_requested" | "quitting" => { if ui[k] != *v { return Err(format!("{k}: got {}, want {v}", ui[k])); diff --git a/crates/ui-egui/src/section_browser_tests.rs b/crates/ui-egui/src/section_browser_tests.rs index f2008b6d18..fa9cca193d 100644 --- a/crates/ui-egui/src/section_browser_tests.rs +++ b/crates/ui-egui/src/section_browser_tests.rs @@ -245,6 +245,7 @@ fn editing_programmatic_sections_keeps_actual_plane_flip_hatch_and_cancel_then_m #[test] fn section_ribbon_toggle_and_setting_edit_have_separate_intent_and_compatibility() { let mut h = harness(); + h.app.preferences.viewport_status_details = false; h.app.run("inspect.zebra", json!({})).unwrap(); h.app.run("inspect.section", json!({"plane":"YZ","offset":3})).unwrap(); h.frames(3); diff --git a/crates/ui-egui/src/selection.rs b/crates/ui-egui/src/selection.rs index 54df89eda9..f8ce333988 100644 --- a/crates/ui-egui/src/selection.rs +++ b/crates/ui-egui/src/selection.rs @@ -257,16 +257,46 @@ pub fn ray_quad(o: Vec3, d: Vec3, q: &[Vec3; 4]) -> Option { } /// A rectangle dragged in the viewport selects by window (dragged left to right: items fully -/// inside) or by crossing (right to left: items touching it). -#[derive(Clone, Copy, Debug, PartialEq)] +/// inside) or by crossing (right to left: items touching it). A freehand lasso (#4376, the +/// selection shape `UiState::lasso_select`) selects by window whichever way it is drawn. +#[derive(Clone, Debug, PartialEq)] pub struct BoxSel { + /// The rectangle, or the bounds of the lasso. pub rect: Rect, pub crossing: bool, + /// The lasso's points in drawing order, closed back to the first; empty for a rectangle. + pub lasso: Vec, } +/// Lasso points closer together than this (screen points) add nothing to its shape. +const LASSO_STEP: f32 = 2.0; + impl BoxSel { pub fn from_drag(start: Pos2, end: Pos2) -> BoxSel { - BoxSel { rect: Rect::from_two_pos(start, end), crossing: end.x < start.x } + BoxSel { rect: Rect::from_two_pos(start, end), crossing: end.x < start.x, lasso: Vec::new() } + } + + /// A lasso through these screen points (non-finite ones are left out). + pub fn lasso(points: &[Pos2]) -> BoxSel { + let mut sel = BoxSel { rect: Rect::NOTHING, crossing: false, lasso: Vec::new() }; + for p in points { + sel.extend(*p); + } + sel + } + + /// Is this a lasso rather than a rectangle? + pub fn is_lasso(&self) -> bool { + !self.lasso.is_empty() + } + + /// The lasso drawn on to `p` (a point too close to the last one adds nothing). + pub fn extend(&mut self, p: Pos2) { + if !p.is_finite() || self.lasso.last().is_some_and(|q| q.distance(p) < LASSO_STEP) { + return; + } + self.lasso.push(p); + self.rect = if self.lasso.len() == 1 { Rect::from_min_max(p, p) } else { self.rect.union(Rect::from_min_max(p, p)) }; } /// Does the polyline (screen points) count as selected? @@ -274,11 +304,36 @@ impl BoxSel { if pts.is_empty() { return false; } + if self.is_lasso() { + return self.lasso_takes(pts); + } if !self.crossing { return pts.iter().all(|p| self.rect.contains(*p)); } pts.iter().any(|p| self.rect.contains(*p)) || pts.windows(2).any(|w| segment_hits_rect(w[0], w[1], self.rect)) } + + /// Every point inside the closed lasso, and no segment crossing its outline (a concave lasso + /// can hold both ends of a segment that leaves it in between). + fn lasso_takes(&self, pts: &[Pos2]) -> bool { + let n = self.lasso.len(); + if n < 3 || !pts.iter().all(|p| self.rect.contains(*p) && inside_polygon(*p, &self.lasso)) { + return false; + } + let edges: Vec<(Pos2, Pos2)> = self.lasso.iter().copied().zip(self.lasso.iter().copied().cycle().skip(1)).collect(); + !pts.windows(2).any(|w| edges.iter().any(|&(r, s)| segments_cross(w[0], w[1], r, s))) + } +} + +/// Is `p` inside the closed polygon (even-odd rule)? +pub fn inside_polygon(p: Pos2, polygon: &[Pos2]) -> bool { + let mut inside = false; + for (a, b) in polygon.iter().zip(polygon.iter().cycle().skip(1)) { + if (a.y > p.y) != (b.y > p.y) && p.x < (b.x - a.x) * (p.y - a.y) / (b.y - a.y) + a.x { + inside = !inside; + } + } + inside } /// Does the segment a–b cross the rectangle's boundary or lie inside it? @@ -349,6 +404,29 @@ mod tests { assert!(c.takes(&inside) && c.takes(&partly) && c.takes(&through) && !c.takes(&away)); } + /// #4376: a freehand lasso takes what is wholly inside it, whichever way it was drawn. An L + /// shaped loop leaves out what sits in its notch, and a segment whose ends are both inside but + /// which cuts across the notch. + #[test] + fn a_lasso_takes_only_what_is_wholly_inside_it() { + let l = [pos2(0.0, 0.0), pos2(100.0, 0.0), pos2(100.0, 40.0), pos2(40.0, 40.0), pos2(40.0, 100.0), pos2(0.0, 100.0)]; + for path in [l.to_vec(), l.iter().rev().copied().collect::>()] { + let lasso = BoxSel::lasso(&path); + assert!(!lasso.crossing, "a lasso selects by window"); + assert!(lasso.takes(&[pos2(10.0, 10.0), pos2(90.0, 20.0)]), "inside the bottom arm"); + assert!(lasso.takes(&[pos2(10.0, 50.0), pos2(30.0, 90.0)]), "inside the left arm"); + assert!(!lasso.takes(&[pos2(60.0, 60.0), pos2(90.0, 90.0)]), "in the notch"); + assert!(!lasso.takes(&[pos2(20.0, 90.0), pos2(90.0, 20.0)]), "both ends inside, across the notch"); + assert!(!lasso.takes(&[pos2(10.0, 10.0), pos2(120.0, 10.0)]), "partly outside"); + assert!(!lasso.takes(&[]), "nothing"); + assert_eq!(lasso.rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 100.0)), "the loop's bounds"); + } + // Fewer than three points enclose nothing; hostile coordinates never panic. + assert!(!BoxSel::lasso(&[pos2(0.0, 0.0), pos2(10.0, 10.0)]).takes(&[pos2(5.0, 5.0)])); + let nan = BoxSel::lasso(&[pos2(f32::NAN, 0.0), pos2(10.0, f32::INFINITY), pos2(0.0, 10.0)]); + assert!(!nan.takes(&[pos2(1.0, 1.0)])); + } + #[test] fn inputs_toggle_and_filter() { let mut inp = SelInput::new("Edges", EDGES, true); diff --git a/crates/ui-egui/src/settings_profiles.rs b/crates/ui-egui/src/settings_profiles.rs index 25df843203..ce3b309931 100644 --- a/crates/ui-egui/src/settings_profiles.rs +++ b/crates/ui-egui/src/settings_profiles.rs @@ -1,14 +1,15 @@ //! Settings profiles (#6237): the input and interface settings kept together under a name, such -//! as "Laptop" and "Large monitor", and switched at once. A profile holds the preferences of the -//! Interface, Navigation and Sketch pages and the language, the keyboard shortcuts, the toolbar -//! and the S box favourites, and the imported theme it uses. Machine-specific settings stay out: -//! the 3D mouse, graphics, recent files and the AI pages. +//! as "Laptop" and "Large monitor", and switched at once. A profile holds the original Interface, +//! Navigation and Sketch preference keys and the language, keyboard shortcuts, toolbar, +//! S box favourites, and imported theme it uses. Page moves preserve that stored key set; +//! device-specific 3D mouse keys, graphics, recent files and Assistant settings stay out. //! //! A profile is kept, exported and imported as one `solidcraft-profile/1` document. Every //! document is checked by [`sanitize`] first: unknown keys are dropped, sizes are capped, and a //! preference this version can't read is left out. Applying one sets each preference through //! [`prefs::set`], so it is checked again and has the same effects as a change in Preferences; //! one that is refused there (an interface font this computer lacks) keeps its current value. +//! Saved theme option maps are restored intact, retaining stale entries for fallback notices. //! //! The `settings.profile.*` commands reach the profiles; the engine only checks their //! parameters, and [`command`] does the rest after `SolveApp::run`. @@ -33,24 +34,108 @@ const MAX_THEMES: usize = 4; /// Most characters in a command id or a key name. const MAX_ID: usize = 200; -/// The Preferences pages whose settings a profile holds. -const PAGES: [usize; 3] = [prefs::PAGE_INTERFACE, prefs::PAGE_NAVIGATION, prefs::PAGE_SKETCH]; -/// Settings a profile holds from other pages. -const ALSO: [&str; 1] = ["language"]; - -/// Settings of those pages that belong to the computer, not the person: the 3D mouse (its -/// calibration, driver and axes) and the cursor flag older releases saved. -fn machine_specific(key: &str) -> bool { - key.starts_with("space_mouse_") || key == "orbit_cursor" -} +/// The 93 stored keys from the pre-split Interface, Navigation and Sketch pages, plus +/// language, excluding space_mouse_* and orbit_cursor. Page moves and title tags must never +/// change profile contents (including the bridge, origin names and direct interaction). +const PROFILE_KEYS: &[&str] = &[ + "browser_bodies_folders", + "browser_collapsed", + "browser_eye_left", + "browser_float", + "browser_share", + "browser_show", + "browser_width", + "center_arc_follows_cursor", + "color_vision", + "dialog_buttons", + "dialog_place", + "direct_interaction", + "free_orbit", + "free_orbit_upright", + "hidden_toolbar_tabs", + "hide_constraint_glyphs_zoomed_out", + "history_in_browser", + "history_play_button", + "history_sketches", + "keep_work_on_tab_switch", + "language", + "layout", + "line_drag_draws_one", + "line_hover_tangent_arc", + "logo_accent", + "look_at_anim", + "look_at_speed", + "manipulator_free_alt", + "middle_double_click", + "mouse_gestures", + "nav", + "nav_bridge_enabled", + "nav_bridge_port", + "navigation_bar", + "navigator_style", + "orbit_around", + "orbit_reverse", + "origin_plane_names", + "pan_reverse_x", + "pan_reverse_y", + "persistent_ribbon_pressed", + "prefer_free_sketch_points", + "project_options_dialog", + "quick_measure", + "radial_context_menu", + "ribbon_icon_grid", + "ribbon_labels", + "ribbon_menus_skip_pinned", + "ribbon_reveal", + "right_drag_orbits", + "select_other_hidden", + "selection_shift_add_only", + "shortcut_cycles_modes", + "sketch_align_points", + "sketch_auto_dimensions", + "sketch_auto_locate", + "sketch_conflict_colour", + "sketch_construction_lines", + "sketch_exit_view", + "sketch_open_look_at", + "sketch_recent_snap", + "sketch_state_icons", + "sketch_tab_always", + "sketch_tracking_guides", + "sketch_tracking_points", + "smooth_wheel_zoom", + "status_bar", + "suppress_sketch_snap", + "theme", + "theme_follow", + "theme_options", + "timeline_collapsed", + "timeline_left", + "timeline_left_browser_collapsed", + "timeline_left_width", + "timeline_origin_bodies", + "timeline_under_browser", + "toast_place", + "trackpad_pan", + "trackpad_zoom_modifier", + "ui_font", + "ui_scale", + "view_confirm_corner", + "viewport_analysis_status", + "viewport_background", + "viewport_section_status", + "viewport_status_details", + "viewport_status_summary", + "viewport_theme", + "wheel_zoom_speed", + "zoom_reverse", + "zoom_stop_at_model", + "zoom_to_cursor", +]; /// The preferences a profile holds, by their stored key. pub fn keys() -> impl Iterator { - prefs::FIELD_PAGES - .iter() - .filter(|(key, page)| ALSO.contains(key) || page.is_some_and(|page| PAGES.contains(&page))) - .map(|(key, _)| *key) - .filter(|key| !machine_specific(key)) + prefs::FIELD_PAGES.iter().map(|(key, _)| *key).filter(|key| PROFILE_KEYS.contains(key)) } /// The kept profiles, which one was applied or saved last, and the Preferences section's state. @@ -243,7 +328,7 @@ fn shortcut_keys(v: &Value) -> Option { keys.iter().all(|k| k.chars().count() <= MAX_ID).then(|| crate::keymap::keys_value(&keys)) } -/// Apply a checked profile: its themes, each preference (through [`prefs::set`]), the +/// Apply a checked profile: its themes, each preference (through [`prefs::set`], except saved option maps), the /// shortcuts, the favourites and the toolbar. Returns the preferences that were refused. fn apply(app: &mut SolveApp, doc: &Value) -> Vec { let mut theme_ids = std::collections::BTreeMap::new(); @@ -255,12 +340,52 @@ fn apply(app: &mut SolveApp, doc: &Value) -> Vec { theme_ids.insert(wanted, id); } } - let mut skipped = Vec::new(); - for (key, value) in doc.get("preferences").and_then(Value::as_object).into_iter().flatten() { - let value = match value.as_str().and_then(|id| theme_ids.get(id)) { - Some(id) if key == "theme" => json!(id), - _ => value.clone(), + // Resolve imported identities before migrating retired built-in cards. Migrate the + // theme and its option maps together: applying `theme` first and then replacing the + // saved maps would otherwise discard the old card's selected contrast. + let mut preferences = doc.get("preferences").and_then(Value::as_object).cloned().unwrap_or_default(); + if let Some(id) = preferences.get("theme").and_then(Value::as_str).and_then(|id| theme_ids.get(id)).cloned() { + preferences.insert("theme".into(), json!(id)); + } + if let Some(options) = preferences.get("theme_options").and_then(Value::as_object) { + let remapped: Map = options.iter().map(|(id, values)| (theme_ids.get(id).unwrap_or(id).clone(), values.clone())).collect(); + preferences.insert("theme_options".into(), Value::Object(remapped)); + } + let options = preferences + .get("theme_options") + .map(|value| serde_json::from_value::>>(value.clone())) + .transpose(); + // Leave malformed raw fields intact for the existing per-key skipped reporting. + // In particular, a bad option map must not become an empty map that clears saved values. + if let Ok(options) = options { + let theme = preferences.get("theme").and_then(Value::as_str).map(str::to_owned); + let legacy = theme.as_deref().and_then(theme_files::legacy_gruvbox).is_some(); + let mut migrated = prefs::Prefs { + theme: theme.clone().unwrap_or_else(|| "dark".into()), + theme_options: options.unwrap_or_else(|| app.preferences.theme_options.clone()), + ..Default::default() }; + prefs::migrate_gruvbox(&mut migrated); + if theme.is_some() { + preferences.insert("theme".into(), json!(migrated.theme)); + } + if preferences.contains_key("theme_options") || legacy { + preferences.insert("theme_options".into(), json!(migrated.theme_options)); + } + } + let mut skipped = Vec::new(); + for (key, value) in &preferences { + if key == "theme_options" { + // Restoring saved state keeps stale entries just like loading preferences. The + // control channel's strict merge is for new edits, not profile restoration. + match serde_json::from_value::>>(value.clone()) { + Ok(options) => { + app.preferences.theme_options = options; + } + Err(_) => skipped.push(key.clone()), + } + continue; + } if let Err(e) = prefs::set(app, &json!({ key: value })) { log::warn!("settings profile: `{key}` keeps its current value: {e}"); skipped.push(key.clone()); @@ -479,3 +604,59 @@ pub(crate) fn section(app: &mut SolveApp, ui: &mut egui::Ui, labels: f32) { } }); } + +/// The pinned profile switcher on every Preferences page. Actions use the same commands as +/// General > Profiles; saving a new name remains in that existing section. +pub(crate) fn sidebar(ui: &mut egui::Ui, app: &mut SolveApp) { + ui_kit::divider(ui, "profile_sidebar"); + ui_kit::text(ui, app.tr("prefs.structure.profile"), ui_kit::Text::Heading("profile_sidebar")); + let active = app.settings_profiles.active.clone(); + let edited = active.as_ref().is_some_and(|name| app.settings_profiles.get(name).is_ok_and(|doc| *doc != capture(app, name))); + let current = active.clone().unwrap_or_else(|| app.tr_label("None").to_string()); + let names = app.settings_profiles.names(); + let mut apply = None; + let mut save = false; + ui.horizontal(|ui| { + let t = crate::theme::Tokens::get(); + let width = ui.available_width() - if edited { t.space_16 + ui.spacing().item_spacing.x } else { 0.0 }; + let response = ui_kit::settings_control_width(ui, width, |ui| { + ui_kit::dropdown_menu(ui, "profile_sidebar", ¤t, |ui| { + for name in &names { + if ui_kit::list_picker(ui, name, name, active.as_ref() == Some(name)).clicked() { + apply = Some(name.clone()); + } + } + if let Some(name) = &active + && ui_kit::button(ui, app.tr("prefs.profile_save"), ui_kit::Button::Normal).clicked() + { + save = true; + apply = None; + app.settings_profiles.draft = name.clone(); + ui.close(); + } + if ui_kit::button(ui, app.tr("prefs.profiles"), ui_kit::Button::Normal).clicked() { + prefs::open_profiles(app); + ui.close(); + } + }) + }); + crate::scenario::publish_target("prefs:profile", response.rect); + if edited { + let dot = ui_kit::status_line(ui, "●", ui_kit::StatusTone::Warning).on_hover_text(app.tr("prefs.structure.profile_edited")); + crate::scenario::publish_target("prefs:profile:edited", dot.rect); + } + }); + if let Some(name) = apply { + app.settings_profiles.draft = name.clone(); + act(app, "settings.profile.apply", json!({"name":name}), "prefs.profile_applied"); + } else if save && let Some(name) = active { + act(app, "settings.profile.save", json!({"name":name}), "prefs.profile_saved"); + } +} + +/// Page-title tag. Membership is descriptive; the stable key list above governs storage. +pub(crate) fn tag(ui: &mut egui::Ui) { + let response = + ui_kit::workspace_chip(ui, crate::i18n::tr("prefs.structure.in_profile")).on_hover_text(crate::i18n::tr("prefs.structure.profile_hover")); + crate::scenario::publish_target("prefs:in_profile", response.rect); +} diff --git a/crates/ui-egui/src/settings_profiles_tests.rs b/crates/ui-egui/src/settings_profiles_tests.rs index d6efe19810..c9fc6dead8 100644 --- a/crates/ui-egui/src/settings_profiles_tests.rs +++ b/crates/ui-egui/src/settings_profiles_tests.rs @@ -35,9 +35,12 @@ fn a_profile_holds_input_and_interface_settings_but_not_the_machine() { "space_mouse_calibrations", "space_mouse_driver", "space_mouse_speed", + "dark_parts_shading", "msaa", "window_system", "default_units", + "extrude_view_isometric", + "extrude_view_return", "update", "agents", "custom_themes", @@ -109,6 +112,84 @@ fn profiles_and_the_active_one_are_kept_between_runs() { assert_eq!(other.settings_profiles.list, h.app.settings_profiles.list); } +#[test] +fn gruvbox_legacy_profiles_keep_their_selected_contrast_with_empty_option_maps() { + let mut h = Harness::new(); + for (old, family, contrast) in crate::theme_files::GRUVBOX_ALIASES { + let profile = json!({"format":FORMAT,"name":old,"preferences":{"theme":old,"theme_options":{}}}); + run(&mut h, "settings.profile.import", json!({"profile":profile})); + let out = run(&mut h, "settings.profile.apply", json!({"name":old})); + assert_eq!(out["skipped"], json!([])); + assert_eq!(h.app.preferences.theme, family); + assert_eq!(h.app.preferences.theme_options[family]["contrast"], contrast); + } + h.app.preferences.theme_options.insert("unrelated".into(), [("future".into(), json!({"keep":true}))].into()); + let profile = json!({"format":FORMAT,"name":"Legacy without options","preferences":{"theme":"gruvbox-light-soft"}}); + run(&mut h, "settings.profile.import", json!({"profile":profile})); + let out = run(&mut h, "settings.profile.apply", json!({"name":"Legacy without options"})); + assert_eq!(out["skipped"], json!([])); + assert_eq!(h.app.preferences.theme_options["gruvbox-light"]["contrast"], "soft"); + assert_eq!(h.app.preferences.theme_options["unrelated"]["future"], json!({"keep":true})); + // A profile's own theme with a retired name is imported under a custom id, + // rather than being mistaken for a built-in contrast selection. + let profile = json!({"format":FORMAT,"name":"Named theme","preferences":{"theme":"gruvbox-dark-hard", + "theme_options":{"gruvbox-dark-hard":{"contrast":"personal"}}}, + "themes":[{"id":"gruvbox-dark-hard","format":"solidcraft-theme/1","name":"Gruvbox Dark Hard","base":"light", + "options":[{"id":"contrast","type":"choice","default":"personal","values":[{"id":"personal","label":{"en":"Personal"}},{"id":"other","label":{"en":"Other"}}]}]}]}); + run(&mut h, "settings.profile.import", json!({"profile":profile})); + let out = run(&mut h, "settings.profile.apply", json!({"name":"Named theme"})); + assert_eq!(out["skipped"], json!([])); + assert_eq!(h.app.preferences.theme, "custom-gruvbox-dark-hard"); + assert_eq!(h.app.preferences.theme_options["custom-gruvbox-dark-hard"]["contrast"], "personal"); +} + +#[test] +fn gruvbox_profile_migration_keeps_malformed_theme_fields_for_skipped_reporting() { + let mut h = Harness::new(); + h.call("ui.prefs", json!({"theme":"gruvbox-light","theme_options":{"gruvbox-light":{"contrast":"soft"}}})); + for bad in [json!({"theme":42,"theme_options":"bad"}), json!({"theme":false,"theme_options":{"gruvbox-dark":"bad"}})] { + let before_theme = h.app.preferences.theme.clone(); + let before_options = h.app.preferences.theme_options.clone(); + let profile = json!({"format":FORMAT,"name":"Malformed","preferences":bad}); + // Keep the per-key apply guard effective even for malformed in-memory documents. + h.app.settings_profiles.list = vec![profile]; + let out = run(&mut h, "settings.profile.apply", json!({"name":"Malformed"})); + assert_eq!(out["skipped"], json!(["theme", "theme_options"])); + assert_eq!(h.app.preferences.theme, before_theme); + assert_eq!(h.app.preferences.theme_options, before_options); + } +} + +#[test] +fn theme_options_profiles_restore_replace_and_preserve_stale_values() { + let mut h = Harness::new(); + let file = json!({ + "format":"solidcraft-theme/1", "name":"Profile Options", "base":"dark", + "options":[{"id":"enabled","type":"bool","default":false}], + "overlays":[{"when":{"opt":"enabled","eq":true},"colors":{"panel":"#abcdef"}}] + }); + let out = h.call("ui.prefs", json!({"theme_import":file,"theme_options":{"profile-options":{"enabled":true}}})); + assert!(out["error"].is_null(), "{out}"); + h.app.preferences.theme_options.entry("profile-options".into()).or_default().insert("future".into(), json!({"unrecognized":true})); + h.app.preferences.theme_options.insert("unknown-theme".into(), [("saved".into(), json!([1, 2]))].into()); + run(&mut h, "settings.profile.save", json!({"name":"Options"})); + let exported = run(&mut h, "settings.profile.export", json!({"name":"Options"}))["profile"].clone(); + let options = exported["preferences"]["theme_options"].clone(); + assert_eq!(exported["themes"][0]["options"][0]["default"], false); + h.call("ui.prefs", json!({"open":"Interface","button":"defaults"})); + run(&mut h, "settings.profile.save", json!({"name":"Empty"})); + run(&mut h, "settings.profile.import", json!({"profile":exported,"name":"Imported"})); + let out = run(&mut h, "settings.profile.apply", json!({"name":"Imported"})); + assert_eq!(out["skipped"], json!([])); + assert_eq!(serde_json::to_value(&h.app.preferences.theme_options).unwrap(), options); + h.frames(2); + assert_eq!(crate::theme::Tokens::get().panel, egui::Color32::from_rgb(0xab, 0xcd, 0xef)); + run(&mut h, "settings.profile.apply", json!({"name":"Empty"})); + assert!(h.app.preferences.theme_options.is_empty()); + run(&mut h, "settings.profile.apply", json!({"name":"Options"})); + assert_eq!(serde_json::to_value(&h.app.preferences.theme_options).unwrap(), options); +} + #[test] fn export_and_import_go_through_one_file_and_import_never_switches() { let dir = std::env::temp_dir().join(format!("solidcraft-profile-test-{}", std::process::id())); @@ -266,3 +347,181 @@ fn choosing_a_profile_in_preferences_applies_it() { assert_eq!(h.app.settings_profiles.active.as_deref(), Some("Large monitor")); assert!(h.app.preferences.zoom_reverse); } + +#[test] +fn profile_keys_preserve_the_literal_pre_split_baseline() { + let expected = [ + "browser_bodies_folders", + "browser_collapsed", + "browser_eye_left", + "browser_float", + "browser_share", + "browser_show", + "browser_width", + "center_arc_follows_cursor", + "color_vision", + "dialog_buttons", + "dialog_place", + "direct_interaction", + "free_orbit", + "free_orbit_upright", + "hidden_toolbar_tabs", + "hide_constraint_glyphs_zoomed_out", + "history_in_browser", + "history_play_button", + "history_sketches", + "keep_work_on_tab_switch", + "language", + "layout", + "line_drag_draws_one", + "line_hover_tangent_arc", + "logo_accent", + "look_at_anim", + "look_at_speed", + "manipulator_free_alt", + "middle_double_click", + "mouse_gestures", + "nav", + "nav_bridge_enabled", + "nav_bridge_port", + "navigation_bar", + "navigator_style", + "orbit_around", + "orbit_reverse", + "origin_plane_names", + "pan_reverse_x", + "pan_reverse_y", + "persistent_ribbon_pressed", + "prefer_free_sketch_points", + "project_options_dialog", + "quick_measure", + "radial_context_menu", + "ribbon_icon_grid", + "ribbon_labels", + "ribbon_menus_skip_pinned", + "ribbon_reveal", + "right_drag_orbits", + "select_other_hidden", + "selection_shift_add_only", + "shortcut_cycles_modes", + "sketch_align_points", + "sketch_auto_dimensions", + "sketch_auto_locate", + "sketch_conflict_colour", + "sketch_construction_lines", + "sketch_exit_view", + "sketch_open_look_at", + "sketch_recent_snap", + "sketch_state_icons", + "sketch_tab_always", + "sketch_tracking_guides", + "sketch_tracking_points", + "smooth_wheel_zoom", + "status_bar", + "suppress_sketch_snap", + "theme", + "theme_follow", + "theme_options", + "timeline_collapsed", + "timeline_left", + "timeline_left_browser_collapsed", + "timeline_left_width", + "timeline_origin_bodies", + "timeline_under_browser", + "toast_place", + "trackpad_pan", + "trackpad_zoom_modifier", + "ui_font", + "ui_scale", + "view_confirm_corner", + "viewport_analysis_status", + "viewport_background", + "viewport_section_status", + "viewport_status_details", + "viewport_status_summary", + "viewport_theme", + "wheel_zoom_speed", + "zoom_reverse", + "zoom_stop_at_model", + "zoom_to_cursor", + ]; + let mut actual: Vec<_> = keys().collect(); + actual.sort_unstable(); + assert_eq!(expected.len(), 93); + assert_eq!(actual, expected); +} + +#[test] +fn moved_profile_settings_survive_saved_roundtrip_and_apply() { + let mut h = Harness::new(); + let defaults = crate::prefs::Prefs::default(); + assert!(!defaults.keep_work_on_tab_switch && !defaults.line_hover_tangent_arc); + for (key, page) in [("keep_work_on_tab_switch", crate::prefs::PAGE_LAYOUT), ("line_hover_tangent_arc", crate::prefs::PAGE_SKETCH)] { + let homes: Vec<_> = crate::prefs::FIELD_PAGES.iter().filter_map(|(field, page)| (*field == key).then_some(*page)).collect(); + assert_eq!(homes, [Some(page)], "{key} keeps its new page home"); + } + h.app.preferences.nav_bridge_enabled = true; + h.app.preferences.nav_bridge_port = 9876; + h.app.preferences.origin_plane_names = crate::prefs::OriginPlaneNames::TopFrontRight; + h.app.preferences.direct_interaction = true; + h.app.preferences.quick_measure = true; + h.app.preferences.viewport_analysis_status = false; + h.app.preferences.keep_work_on_tab_switch = true; + h.app.preferences.line_hover_tangent_arc = true; + run(&mut h, "settings.profile.save", json!({"name":"Moved"})); + let saved = h.app.settings_profiles.prefs(); + let doc = h.app.settings_profiles.list[0].clone(); + assert_eq!(doc["preferences"]["keep_work_on_tab_switch"], true); + assert_eq!(doc["preferences"]["line_hover_tangent_arc"], true); + assert!(doc["preferences"].get("dark_parts_shading").is_none()); + assert_eq!(sanitize(&doc).unwrap(), doc); + h.app.settings_profiles.load(&serde_json::from_str::(&serde_json::to_string(&saved).unwrap()).unwrap()); + assert_eq!(h.app.settings_profiles.list[0], doc); + h.app.preferences.nav_bridge_enabled = false; + h.app.preferences.nav_bridge_port = 8765; + h.app.preferences.origin_plane_names = crate::prefs::OriginPlaneNames::Xyz; + h.app.preferences.direct_interaction = false; + h.app.preferences.quick_measure = false; + h.app.preferences.viewport_analysis_status = true; + h.app.preferences.keep_work_on_tab_switch = false; + h.app.preferences.line_hover_tangent_arc = false; + let out = run(&mut h, "settings.profile.apply", json!({"name":"Moved"})); + assert_eq!(out["skipped"], json!([])); + assert!(h.app.preferences.keep_work_on_tab_switch && h.app.preferences.line_hover_tangent_arc); + assert_eq!(capture(&h.app, "Moved"), doc); +} + +#[test] +fn profile_sidebar_switches_saves_and_marks_edits_with_existing_commands() { + let mut h = Harness::new(); + h.app.home.open = false; + run(&mut h, "settings.profile.save", json!({"name":"Laptop"})); + h.app.preferences.zoom_reverse = true; + run(&mut h, "settings.profile.save", json!({"name":"Desk"})); + h.call("ui.prefs", json!({"open":"Tools"})); + h.frames(3); + assert!(crate::scenario::rect_of("prefs:profile:edited").is_none()); + h.step(&json!({"click":{"handle":"prefs:profile"}})).unwrap(); + h.frames(2); + h.step(&json!({"click":{"handle":"dialog:list:Laptop"}})).unwrap(); + h.frames(3); + assert_eq!(h.app.settings_profiles.active.as_deref(), Some("Laptop")); + assert!(!h.app.preferences.zoom_reverse); + h.app.preferences.direct_interaction = true; + h.frames(3); + assert!(crate::scenario::rect_of("prefs:profile:edited").is_some()); + h.step(&json!({"click":{"handle":"prefs:profile"}})).unwrap(); + h.frames(2); + h.step(&json!({"widget":"Save"})).unwrap(); + h.frames(3); + assert!(crate::scenario::rect_of("prefs:profile:edited").is_none()); + assert!(h.app.prefs_window.open, "saving a profile keeps Preferences open"); + let saved = run(&mut h, "settings.profile.export", json!({"name":"Laptop"})); + assert_eq!(saved["profile"]["preferences"]["direct_interaction"], true); + h.step(&json!({"click":{"handle":"prefs:profile"}})).unwrap(); + h.frames(2); + let profiles_label = h.app.tr("prefs.profiles").to_string(); + h.step(&json!({"widget":profiles_label})).unwrap(); + h.frames(3); + assert_eq!(h.app.prefs_window.section, crate::prefs::PAGE_GENERAL); +} diff --git a/crates/ui-egui/src/shell_tests.rs b/crates/ui-egui/src/shell_tests.rs index d78e478e0a..2cb1a4062a 100644 --- a/crates/ui-egui/src/shell_tests.rs +++ b/crates/ui-egui/src/shell_tests.rs @@ -592,7 +592,7 @@ fn compact_layout_is_saved_and_survives_cancel_apply_defaults_and_bad_input() { let design = h.app.session.doc.to_json(); let undo = h.app.session.undo.len(); let camera = h.app.cam; - h.call("ui.prefs", json!({"open":"Interface"})); + h.call("ui.prefs", json!({"open":"Layout"})); crate::prefs::reveal_preference(&mut h, "dialog:Workspace:Compact"); h.step(&json!({"click":{"handle":"dialog:Workspace:Compact"}})).unwrap(); h.call("ui.prefs", json!({"button":"ok"})); @@ -609,15 +609,15 @@ fn compact_layout_is_saved_and_survives_cancel_apply_defaults_and_bad_input() { restored.load_prefs(&h.app.prefs()); assert_eq!(restored.ui.layout, LayoutMode::Docked); assert_eq!(restored.preferences.layout, LayoutMode::Docked); - h.call("ui.prefs", json!({"open":"Interface","layout":"floating"})); + h.call("ui.prefs", json!({"open":"Layout","layout":"floating"})); h.call("ui.prefs", json!({"button":"cancel"})); assert_eq!(h.app.ui.layout, LayoutMode::Docked); - h.call("ui.prefs", json!({"open":"Interface","layout":"floating"})); + h.call("ui.prefs", json!({"open":"Layout","layout":"floating"})); h.call("ui.prefs", json!({"button":"apply"})); h.call("ui.prefs", json!({"layout":"compact"})); h.call("ui.prefs", json!({"button":"cancel"})); assert_eq!(h.app.ui.layout, LayoutMode::Floating); - h.call("ui.prefs", json!({"open":"Interface","layout":"compact"})); + h.call("ui.prefs", json!({"open":"Layout","layout":"compact"})); h.call("ui.prefs", json!({"button":"defaults"})); assert_eq!(h.app.ui.layout, LayoutMode::Floating); assert_eq!(h.app.preferences.layout, LayoutMode::Floating); @@ -929,7 +929,7 @@ fn the_view_corner_answers_a_dialog_in_the_left_column() { } } -/// Dialogs is an Interface preference: set from the page or the control channel, saved, +/// Dialogs is a Layout preference: set from the page or the control channel, saved, /// refused when unknown, and Cancel and Restore Defaults put it back. A dialog already open /// moves with it. #[test] @@ -941,7 +941,7 @@ fn dialog_place_is_saved_and_survives_cancel_defaults_and_bad_input() { h.settle(); let right = h.app.viewport.dialog_rect.unwrap(); assert_eq!(h.app.preferences.dialog_place, DialogPlace::Edge); - h.call("ui.prefs", json!({"open":"Interface"})); + h.call("ui.prefs", json!({"open":"Layout"})); crate::prefs::reveal_preference(&mut h, "dialog:Dialogs:In the left column"); h.step(&json!({"click":{"handle":"dialog:Dialogs:In the left column"}})).unwrap(); assert_eq!(h.app.preferences.dialog_place, DialogPlace::LeftColumn); @@ -952,10 +952,10 @@ fn dialog_place_is_saved_and_survives_cancel_defaults_and_bad_input() { let mut restored = crate::SolveApp::new(Default::default(), Default::default()); restored.load_prefs(&h.app.prefs()); assert_eq!(restored.preferences.dialog_place, DialogPlace::LeftColumn); - h.call("ui.prefs", json!({"open":"Interface","dialog_place":"edge"})); + h.call("ui.prefs", json!({"open":"Layout","dialog_place":"edge"})); h.call("ui.prefs", json!({"button":"cancel"})); assert_eq!(h.app.preferences.dialog_place, DialogPlace::LeftColumn); - h.call("ui.prefs", json!({"open":"Interface"})); + h.call("ui.prefs", json!({"open":"Layout"})); h.call("ui.prefs", json!({"button":"defaults"})); assert_eq!(h.app.preferences.dialog_place, DialogPlace::Edge); h.call("ui.prefs", json!({"button":"ok"})); diff --git a/crates/ui-egui/src/sketch_attachment_ui_tests.rs b/crates/ui-egui/src/sketch_attachment_ui_tests.rs index 46f4472eca..53f1274c70 100644 --- a/crates/ui-egui/src/sketch_attachment_ui_tests.rs +++ b/crates/ui-egui/src/sketch_attachment_ui_tests.rs @@ -421,3 +421,35 @@ fn redefine_panel_names_the_plane_a_sketch_is_on() { assert_eq!(plane_description(&h.app.session.doc, &offset), "The XY plane, offset by 12 mm"); assert_eq!(plane_description(&h.app.session.doc, &PlaneRef::Flipped { base: xy, about_y: false }), "The XY plane, from its other side"); } + +/// Preferences › Sketch › "Sketching on a part's face makes that part active" (#10215): a face of +/// a part's body picked for Create Sketch while the design is active puts the sketch in the part, +/// which stays active; off, the sketch belongs to the design. +#[test] +fn face_pick_makes_its_part_active_only_with_the_preference() { + for on in [true, false] { + let mut h = Harness::new(); + h.app.set_origin_shown(false); + h.app.session.auto_project = false; + h.app.preferences.sketch_face_activates_part = on; + let part = h.app.run("component.create", json!({"name":"Part"})).unwrap(); + let (component, occurrence) = (part["component"].as_u64().unwrap(), part["occurrence"].as_u64().unwrap()); + h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"PartBox"})).unwrap(); + h.app.run("component.activate", json!({"component":0})).unwrap(); + h.call("ui.view", json!({"view":"top"})); + h.call("ui.view", json!({"view":"fit"})); + h.settle(); + h.step(&json!({"start":"sketch.create"})).unwrap(); + h.step(&json!({"click":{"world":[3,4,10]}})).unwrap(); + let sketch = h.app.session.active_sketch.expect("the picked face takes a sketch"); + let owner = h.app.session.doc.feature(sketch).unwrap().component; + if on { + assert_eq!(owner, component); + assert_eq!((h.app.session.active_component, h.app.session.active_occurrence_path.clone()), (component, Some(vec![occurrence]))); + } else { + assert_eq!((owner, h.app.session.active_component), (0, 0)); + } + h.app.finish_sketch(); + assert_eq!(h.app.session.active_component, if on { component } else { 0 }, "the part stays active as after Activate"); + } +} diff --git a/crates/ui-egui/src/sketch_continuity_tests.rs b/crates/ui-egui/src/sketch_continuity_tests.rs index b624c8ab6e..d53c925376 100644 --- a/crates/ui-egui/src/sketch_continuity_tests.rs +++ b/crates/ui-egui/src/sketch_continuity_tests.rs @@ -48,6 +48,7 @@ fn a_tangent_seam_is_labelled_with_its_curvature_jump() { #[test] fn the_legend_shows_while_seams_are_shaded() { let mut h = crate::scenario::Harness::new(); + h.app.preferences.viewport_status_details = false; h.app.run("solid.box", json!({"length": 20, "width": 20, "height": 20})).unwrap(); h.settle(); assert!(crate::scenario::rect_of("continuity_legend").is_none()); diff --git a/crates/ui-egui/src/sketch_dims.rs b/crates/ui-egui/src/sketch_dims.rs index df13d3f97c..b9da03821b 100644 --- a/crates/ui-egui/src/sketch_dims.rs +++ b/crates/ui-egui/src/sketch_dims.rs @@ -531,11 +531,31 @@ fn boxes(app: &SolveApp, ui: &mut egui::Ui, proj: &Proj, t: &mut Tool) -> Option let mut completed = false; let mut any_focused = false; let view = app.viewport.layout.as_ref().map(|l| l.drawable).filter(|r| r.is_finite() && r.is_positive()); - for (i, b) in t.dims.iter_mut().enumerate() { - let Some((v, at)) = lv.get(i).copied() else { continue }; + let field_frame = Container::popup_frame(ui.style()).inner_margin(egui::Margin::symmetric(tk.space_3 as i8, 1)); + for (b, (v, _)) in t.dims.iter_mut().zip(&lv) { if !b.locked { - b.value = fmt(app, b.label, b.kind, v); + b.value = fmt(app, b.label, b.kind, *v); } + } + // Polygon values used to sit inside the radius, so both boxes covered a small polygon + // and each other. Place them together outside its projected outline, in screen space: + // their separation must not shrink with the shape or a tilted sketch plane. + let polygon_positions = if matches!(t.cmd, "sketch.polygon.inscribed" | "sketch.polygon.circumscribed") { + let corners = crate::tools::polygon_outline(t, e, locked(app, t, "Sides").unwrap_or(6.0)) + .unwrap_or_else(|| t.pts.iter().map(|p| p.0).chain(std::iter::once(e)).collect()); + let points: Vec<_> = corners.iter().filter_map(|p| proj.to_screen(plane.to_world(*p))).collect(); + let bounds = egui::Rect::from_points(&points); + let sizes: Vec<_> = t + .dims + .iter() + .map(|b| crate::ui_kit::placement_field_content_size(ui, &b.value) + vec2(2.0 * tk.form_inset, 0.0) + field_frame.total_margin().sum()) + .collect(); + bounds.is_finite().then(|| polygon_input_positions(bounds, &sizes, view, tk.space_8)) + } else { + None + }; + for (i, b) in t.dims.iter_mut().enumerate() { + let Some((_, at)) = lv.get(i).copied() else { continue }; let Some(sp) = proj.to_screen(plane.to_world(at)) else { continue }; let id = egui::Id::new(("sc_dim", i)); let has_focus = ui.ctx().memory(|m| m.has_focus(id)); @@ -546,13 +566,16 @@ fn boxes(app: &SolveApp, ui: &mut egui::Ui, proj: &Proj, t: &mut Tool) -> Option s.cursor.set_char_range(Some(egui::text_selection::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(n)))); s.store(ui.ctx(), id); } - let pos = in_view(sp + vec2(-34.0, -11.0), view, ui.ctx().pointer_latest_pos()); + let pos = polygon_positions + .as_ref() + .and_then(|positions| positions.get(i).copied()) + .unwrap_or_else(|| in_view(sp + vec2(-34.0, -11.0), view, ui.ctx().pointer_latest_pos())); // Background: under Preferences and Report a bug (#9016), like canvas value boxes. Container::new(ui.ctx(), Surface::Canvas).area(egui::Id::new(("sc_dim_area", i))).fixed_pos(pos).order(egui::Order::Background).show( ui.ctx(), |ui| { let fill = if b.locked { tk.accent_soft } else { tk.panel }; - Container::popup_frame(ui.style()).fill(fill).inner_margin(egui::Margin::symmetric(tk.space_3 as i8, 1)).show(ui, |ui| { + field_frame.fill(fill).show(ui, |ui| { let r = crate::ui_kit::placement_field(ui, id, &mut b.value, b.label); // A completion gets first use of forward Tab. Shift+Tab always moves back; // the box reached by egui's generic traversal must not complete its text. @@ -590,6 +613,34 @@ fn boxes(app: &SolveApp, ui: &mut egui::Ui, proj: &Proj, t: &mut Tool) -> Option (enter && (any_focused || nothing)).then_some(e) } +/// Stack the polygon inputs beside its screen bounds. Try each side before falling back to +/// the drawing area's edge when the polygon fills the view; keep the boxes separate even then. +fn polygon_input_positions(bounds: egui::Rect, sizes: &[egui::Vec2], view: Option, gap: f32) -> Vec { + let size = + vec2(sizes.iter().map(|s| s.x).fold(0.0, f32::max), sizes.iter().map(|s| s.y).sum::() + gap * sizes.len().saturating_sub(1) as f32); + let candidates = [ + egui::pos2(bounds.center().x - size.x * 0.5, bounds.top() - gap - size.y), + egui::pos2(bounds.center().x - size.x * 0.5, bounds.bottom() + gap), + egui::pos2(bounds.left() - gap - size.x, bounds.center().y - size.y * 0.5), + egui::pos2(bounds.right() + gap, bounds.center().y - size.y * 0.5), + ]; + let inside = |pos: Pos2| view.map_or(pos, |view| pos.clamp(view.min, (view.max - size).max(view.min))); + let start = candidates + .into_iter() + .map(inside) + .find(|pos| !egui::Rect::from_min_size(*pos, size).intersects(bounds.expand(gap * 0.5))) + .unwrap_or_else(|| inside(candidates[0])); + let mut y = start.y; + sizes + .iter() + .map(|size| { + let pos = egui::pos2(start.x, y); + y += size.y + gap; + pos + }) + .collect() +} + /// About the size of a value box, for keeping it in view. pub(crate) const BOX: egui::Vec2 = egui::vec2(76.0, 24.0); diff --git a/crates/ui-egui/src/sketch_dims_tests.rs b/crates/ui-egui/src/sketch_dims_tests.rs index 84ce24b9a3..c11bd0eb53 100644 --- a/crates/ui-egui/src/sketch_dims_tests.rs +++ b/crates/ui-egui/src/sketch_dims_tests.rs @@ -568,6 +568,61 @@ fn value_boxes_stay_in_view_and_off_the_cursor() { assert!(tiny.contains_rect(egui::Rect::from_min_size(in_view(egui::pos2(-200.0, -200.0), Some(tiny), Some(egui::pos2(40.0, 15.0))), BOX))); } +#[test] +fn polygon_inputs_clear_each_other_and_the_preview() { + for command in ["sketch.polygon.inscribed", "sketch.polygon.circumscribed"] { + for (view, corner) in [("top", false), ("iso", false), ("top", true)] { + let mut h = Harness::new(); + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + h.call("ui.view", json!({"view":view})); + h.step(&json!({"start":command})).unwrap(); + if corner { + let view = h.app.viewport.layout.as_ref().unwrap().drawable.shrink(20.0); + let center = view.left_top(); + let end = center + egui::vec2(16.0, 0.0); + h.step(&json!({"click":[center.x,center.y]})).unwrap(); + h.step(&json!({"move":[end.x,end.y],"alt":true})).unwrap(); + } else { + h.step(&json!({"click":{"sketch":[0,0]}})).unwrap(); + h.step(&json!({"move":{"sketch":[2,0]},"alt":true})).unwrap(); + } + h.frames(3); + let boxes: Vec<_> = (0..2usize) + .map(|i| { + h.ctx + .memory(|m| m.area_rect(egui::Id::new(("sc_dim_area", i)))) + .unwrap_or_else(|| panic!("{command}, {view}, {corner:?}: input {i} missing")) + }) + .collect(); + assert!(!boxes[0].intersects(boxes[1]), "{command}: inputs overlap: {boxes:?}"); + let tool = h.app.tool.as_ref().unwrap(); + let end = crate::sketch_dims::shown(&h.app, tool, tool.hover.as_ref().unwrap()); + let outline = crate::tools::polygon_outline(tool, end, 6.0).unwrap(); + let state = h.app.session.world_state(); + let plane = state.sketch(h.app.session.active_sketch.unwrap()).unwrap().plane; + let proj = crate::viewport::projection(&h.app, h.app.viewport.rect.unwrap()); + let points: Vec<_> = outline.iter().map(|p| proj.to_screen(plane.to_world(*p)).unwrap()).collect(); + let bounds = egui::Rect::from_points(&points); + for rect in boxes { + assert!(!rect.intersects(bounds), "{command}: input {rect:?} covers polygon {bounds:?}"); + assert!(h.app.viewport.layout.as_ref().unwrap().drawable.contains_rect(rect)); + } + assert_focus(&h, 0); + type_value(&mut h, "2"); + tab(&mut h, false); + assert_focus(&h, 1); + type_value(&mut h, "6"); + h.step(&json!({"key":"Enter"})).unwrap(); + let state = h.app.session.world_state(); + let sketch = state.sketch(h.app.session.active_sketch.unwrap()).unwrap(); + assert_eq!(sketch.sketch.curves.iter().filter(|c| !c.construction).count(), 6); + assert_eq!(sketch.profiles.len(), 1); + let expected = if command == "sketch.polygon.inscribed" { 6.0 * 3f64.sqrt() } else { 8.0 * 3f64.sqrt() }; + assert!((sketch.profiles[0].area - expected).abs() < 1e-6, "{command}: {}", sketch.profiles[0].area); + } + } +} + /// `name = expression` in a value box, as in the Dimension tool's: the shape takes the /// expression's size while it is typed and when placed, and the new parameter `name` drives its /// dimension (#7567). diff --git a/crates/ui-egui/src/sketch_edit_tests.rs b/crates/ui-egui/src/sketch_edit_tests.rs index 77acff8bb5..5617c07d1c 100644 --- a/crates/ui-egui/src/sketch_edit_tests.rs +++ b/crates/ui-egui/src/sketch_edit_tests.rs @@ -83,6 +83,93 @@ fn toolbar_finish_and_other_feature_edit_leave_the_sketch() { assert_eq!(app.session.active_sketch, None); } +fn two_finished_sketches() -> (crate::scenario::Harness, Vec) { + use serde_json::json; + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + let mut ids = Vec::new(); + for name in ["First", "Second"] { + let created = h.app.run("sketch.create", json!({"plane": "XY", "name": name})).unwrap(); + ids.push(created["sketch"].as_u64().unwrap()); + h.app.run("sketch.line", json!({"points": [[0, 0], [20, 10]]})).unwrap(); + h.app.run("sketch.finish", json!({})).unwrap(); + } + h.settle(); + (h, ids) +} + +#[test] +fn ribbon_edit_sketch_opens_the_selected_sketch_and_finish_restores_the_marker() { + use serde_json::json; + let (mut h, ids) = two_finished_sketches(); + let marker = h.app.session.doc.marker; + h.app.run("select.set", json!({"items": [{"type": "feature", "id": ids[0]}]})).unwrap(); + h.step(&json!({"start": "sketch.edit"})).unwrap(); + h.settle(); + assert_eq!(h.app.session.active_sketch, Some(ids[0])); + assert_eq!(h.app.session.doc.marker, Some(1)); + h.step(&json!({"start": "sketch.finish"})).unwrap(); + h.settle(); + assert_eq!(h.app.session.active_sketch, None); + assert_eq!(h.app.session.doc.marker, marker); +} + +#[test] +fn ribbon_edit_sketch_offers_a_choice_without_a_unique_selection() { + use serde_json::json; + for selected in [false, true] { + let (mut h, ids) = two_finished_sketches(); + if selected { + h.app + .run( + "select.set", + json!({"items": [ + {"type": "feature", "id": ids[0]}, {"type": "feature", "id": ids[1]} + ]}), + ) + .unwrap(); + } + h.step(&json!({"start": "sketch.edit"})).unwrap(); + h.settle(); + assert_eq!(h.app.session.active_sketch, None, "do not choose an arbitrary sketch"); + assert!(h.app.viewport.context_menu.is_some(), "offer the sketches to edit"); + h.step(&json!({"call": "ui.menuPick", "params": {"item": "Second"}})).unwrap(); + h.settle(); + assert_eq!(h.app.session.active_sketch, Some(ids[1])); + h.step(&json!({"start": "sketch.finish"})).unwrap(); + h.settle(); + assert_eq!(h.app.session.doc.marker, None); + } +} + +#[test] +fn ribbon_edit_sketch_uses_the_owner_of_selected_sketch_geometry() { + use serde_json::json; + use solidcraft_engine::Sel; + let (mut h, ids) = two_finished_sketches(); + h.app.session.selection = + vec![Sel::ExternalSketchCurve { sketch: ids[1], id: "l1".into() }, Sel::ExternalSketchPoint { sketch: ids[1], id: "p1".into() }]; + h.step(&json!({"start": "sketch.edit"})).unwrap(); + h.settle(); + assert_eq!(h.app.session.active_sketch, Some(ids[1])); +} + +#[test] +fn ribbon_edit_sketch_handles_an_empty_design_without_running_a_command() { + use serde_json::json; + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.step(&json!({"start": "sketch.edit"})).unwrap(); + h.settle(); + let items = crate::context_menu::items(&h.app, &h.app.menu.target); + assert_eq!(items.len(), 1); + assert_eq!(items[0].label, "No sketches to edit"); + assert!(!items[0].enabled); + assert_eq!(h.app.session.active_sketch, None); + assert!(h.app.session.doc.features.is_empty()); + assert!(h.app.session.execute("sketch.edit", &json!({})).is_err(), "the programmatic command still requires a sketch"); +} + /// Leaving a sketch any way leaves the camera exactly where it is; entering still faces the plane. mod exit_keeps_view { use crate::scenario::Harness; @@ -199,6 +286,32 @@ mod view_corner { assert!(h.app.session.doc.features.is_empty(), "a new sketch goes with what was drawn in it"); } + /// With a line half drawn, the cross takes the same steps as Esc: the placed point, then the + /// tool, and only then the sketch, which keeps what was drawn until that last click. + #[test] + fn the_cross_cancels_an_unfinished_tool_before_the_sketch() { + let mut h = sketching(); + h.step(&json!({"start": "sketch.line"})).unwrap(); + assert!(h.app.tool.is_some(), "the line tool is out"); + h.step(&json!({"click": {"sketch": [10, 20]}})).unwrap(); + assert_eq!(h.app.tool.as_ref().map(|tool| tool.pts.len()), Some(1), "the line's first point is down"); + let cross = |h: &mut Harness| { + h.step(&json!({"click": {"handle": "view:confirm:cancel"}})).unwrap(); + h.settle(); + }; + + cross(&mut h); + assert_eq!(h.app.tool.as_ref().map(|tool| tool.pts.len()), Some(0), "the first click takes the point back"); + assert!(h.app.session.active_sketch.is_some()); + cross(&mut h); + assert!(h.app.tool.is_none(), "the second puts the tool down"); + assert!(h.app.session.active_sketch.is_some(), "and keeps the sketch open"); + assert_eq!(scenario::count_of("view:confirm:ok:enabled"), Some(1.0)); + cross(&mut h); + assert_eq!(h.app.session.active_sketch, None, "with nothing unfinished it cancels the sketch"); + assert!(h.app.session.doc.features.is_empty()); + } + #[test] fn a_refused_cancel_shows_its_error_and_keeps_the_sketch() { let mut h = Harness::new(); @@ -368,6 +481,86 @@ mod exit_view_preference { } } +/// Preferences › Design › Extrude's view (#9176): with the first preference on, the view turns to +/// isometric each time Extrude opens; with the second as well, it goes back to the view from +/// before when Extrude closes, by OK, Cancel or Esc. Both are off by default: the view stays. +mod extrude_view_preference { + use crate::scenario::Harness; + use serde_json::json; + use solidcraft_engine::render::{Camera, StandardView}; + + /// A finished sketch on XY, seen from the top, with the two preferences set; and that view. + fn from_the_top(isometric: bool, back: bool) -> (Harness, Camera) { + let mut h = Harness::new(); + h.app.preferences.extrude_view_isometric = isometric; + h.app.preferences.extrude_view_return = back; + h.app.run("sketch.create", json!({"plane": "XY"})).unwrap(); + h.app.run("sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]})).unwrap(); + h.app.run("sketch.finish", json!({})).unwrap(); + h.call("ui.view", json!({"view": "top"})); + h.settle(); + crate::scenario::check(&mut h, &json!({"camera": {"yaw_deg": 0, "pitch_deg": 90}})).unwrap(); + let top = h.app.cam; + (h, top) + } + + fn open_extrude(h: &mut Harness) { + h.step(&json!({"start": "solid.extrude"})).unwrap(); + h.settle(); + assert!(h.app.dialog.is_some(), "Extrude is open"); + } + + fn iso_of(cam: Camera) -> Camera { + let mut iso = cam; + iso.set_view(StandardView::Iso); + iso + } + + #[test] + fn both_are_off_by_default_and_the_view_stays() { + let prefs = crate::prefs::Prefs::default(); + assert!(!prefs.extrude_view_isometric && !prefs.extrude_view_return); + let (mut h, top) = from_the_top(false, false); + open_extrude(&mut h); + assert_eq!(h.app.cam, top); + // The second one alone does nothing either. + let (mut h, top) = from_the_top(false, true); + open_extrude(&mut h); + assert_eq!(h.app.cam, top); + } + + #[test] + fn extrude_turns_the_view_to_isometric_and_leaves_it_there() { + let (mut h, top) = from_the_top(true, false); + open_extrude(&mut h); + assert_eq!(h.app.cam, iso_of(top), "isometric when Extrude opens"); + h.step(&json!({"widget": "Cancel"})).unwrap(); + h.settle(); + assert!(h.app.dialog.is_none()); + assert_eq!(h.app.cam, iso_of(top), "without the second preference it stays isometric"); + } + + #[test] + fn ok_cancel_and_escape_go_back_to_the_view_from_before() { + for close in ["OK", "Cancel", "Escape"] { + let (mut h, top) = from_the_top(true, true); + open_extrude(&mut h); + assert_eq!(h.app.cam, iso_of(top), "{close}: isometric while Extrude is open"); + match close { + "Escape" => h.step(&json!({"key": "Escape"})).unwrap(), + other => h.step(&json!({"widget": other})).unwrap(), + }; + h.settle(); + assert!(h.app.dialog.is_none(), "{close}"); + assert_eq!(h.app.session.doc.features.len(), if close == "OK" { 2 } else { 1 }, "{close}: OK adds the extrude"); + assert_eq!(h.app.cam, top, "{close}: back to the view from before"); + // Each time it opens, not only the first. + open_extrude(&mut h); + assert_eq!(h.app.cam, iso_of(top), "{close}: isometric again"); + } + } +} + /// #8127: a sketch inside an inserted fastener, opened from its row in the browser, activates the /// fastener's component while it is open; Finish and Cancel make the design active again, so the /// other bodies are no longer drawn faded. Editing it with the fastener already active leaves the diff --git a/crates/ui-egui/src/sketch_guidance_ui_tests.rs b/crates/ui-egui/src/sketch_guidance_ui_tests.rs index 8a7f57dade..df2977d8ba 100644 --- a/crates/ui-egui/src/sketch_guidance_ui_tests.rs +++ b/crates/ui-egui/src/sketch_guidance_ui_tests.rs @@ -307,6 +307,62 @@ fn centre_and_midpoint_tracking_is_visual_in_idle_and_drawing_modes() { assert_eq!(h.app.session.undo.len(), undo); } +/// #9105: while a drawing tool waits for a point, a rectangle corner starts a guide as a +/// midpoint does; idle hovering and the preference turned off keep the old centre/MID-only guides. +#[test] +fn a_corner_starts_a_guide_while_drawing_unless_turned_off() { + use solidcraft_engine::geom::Vec2; + let mut h = tracking_fixture(); + assert!(h.app.preferences.sketch_tracking_points, "on by default"); + let before = h.app.session.doc.to_json(); + let corner = Vec2::new(30.0, 20.0); + let hover_corner_then_level = |h: &mut Harness| { + step(h, json!({"move":{"sketch":[30,20]}})); + step(h, json!({"move":{"sketch":[45,20]}})); + h.app.viewport.tracking.guide + }; + assert!(hover_corner_then_level(&mut h).is_none(), "idle: a corner is no reference"); + for tool in ["sketch.line", "sketch.rectangle.two_point", "sketch.circle.center"] { + h.app.start(tool); + let (a, b) = hover_corner_then_level(&mut h).unwrap_or_else(|| panic!("{tool}: a guide from the corner")); + assert!(a.dist(corner) < 1e-9 && b.dist(Vec2::new(45.0, 20.0)) < 1e-4, "{tool}: {a:?} {b:?}"); + step(&mut h, json!({"key":"Escape"})); + } + h.call("ui.prefs", json!({"sketch_tracking_points": false})); + h.app.start("sketch.line"); + assert!(hover_corner_then_level(&mut h).is_none(), "the preference keeps the old guides"); + step(&mut h, json!({"move":{"sketch":[0,20]}})); + step(&mut h, json!({"move":{"sketch":[0,-10]}})); + assert!(h.app.viewport.tracking.guide.is_some(), "midpoints still guide"); + step(&mut h, json!({"key":"Escape"})); + assert_eq!(h.app.session.doc.to_json(), before, "guides add no geometry or constraints"); +} + +#[test] +fn a_rectangle_snaps_to_an_infinite_line_crossing() { + use solidcraft_engine::geom::Vec2; + let mut h = Harness::new(); + h.app.preferences.look_at_anim = false; + for (command, params) in [ + ("sketch.create", json!({"plane":"XY"})), + ("sketch.construction_line.vertical", json!({"at":[20,40]})), + ("sketch.construction_line.horizontal", json!({"at":[40,15]})), + ] { + h.app.run(command, params).unwrap(); + } + h.call("ui.camera", json!({"target":[20,15,0],"yaw":0,"pitch":std::f64::consts::FRAC_PI_2,"distance":120})); + step(&mut h, json!({"start":"sketch.rectangle.two_point"})); + step(&mut h, json!({"move":{"sketch":[20.4,15.4]}})); + step(&mut h, json!({"click":{"sketch":[20.4,15.4]}})); + let corner = h.app.tool.as_ref().unwrap().pts[0].0; + assert!(corner.dist(Vec2::new(20.0, 15.0)) < 1e-9, "the infinite lines cross beyond their defining segments: {corner:?}"); + step(&mut h, json!({"click":{"sketch":[30,25]}})); + step(&mut h, json!({"key":"Escape"})); + let sk = h.app.session.doc.sketch(h.app.session.active_sketch.unwrap()).unwrap(); + assert_eq!(sk.curves.len(), 6, "two construction lines and the rectangle"); + assert!(sk.points.iter().any(|p| p.pos.dist(Vec2::new(20.0, 15.0)) < 1e-9), "the committed rectangle keeps the snapped corner"); +} + #[test] fn two_recent_midpoints_snap_where_their_guides_meet() { use solidcraft_engine::geom::Vec2; diff --git a/crates/ui-egui/src/sketch_line_arc.rs b/crates/ui-egui/src/sketch_line_arc.rs new file mode 100644 index 0000000000..4ee31e69be --- /dev/null +++ b/crates/ui-egui/src/sketch_line_arc.rs @@ -0,0 +1,187 @@ +//! The Line tool's switch to tangent arcs and back (#9489), with Preferences › Sketch asking for +//! it: leave the chain's last point, come back and hold the pointer still on it. While switched +//! the tool is `sketch.arc.tangent`, so the arc is that command's and whatever asks which tool is +//! out gets the true answer; [`LineChain`] is what makes it part of the chain. + +use egui::{Pos2, Stroke}; +use serde_json::{Value, json}; +use solidcraft_engine::geom::Vec2; +use solidcraft_engine::sketch::Sketch; + +use crate::SolveApp; +use crate::tools::Tool; +use crate::viewport::Proj; + +const LINE: &str = "sketch.line"; +const TANGENT_ARC: &str = "sketch.arc.tangent"; +/// How long the pointer rests on the point before the mode switches (seconds). +const REST_SECONDS: f64 = 0.35; +/// How near the point counts as on it (screen points). +const ON_POINT: f32 = 8.0; +/// How far the pointer must go from the point first (screen points). A click leaves the pointer on +/// the point it placed, and a pause there to think must not switch anything. +const AWAY: f32 = 20.0; +/// How far a resting pointer may drift, as a hand does (screen points). +const STILL: f32 = 2.0; + +/// Where a Line tool's chain has got to. +#[derive(Clone, Debug, Default)] +pub struct LineChain { + /// The curve the chain's last point ends. `None` until the chain has drawn one. + curve: Option, + /// The pointer has been away from that point since it was placed or the mode last switched. + left: bool, + /// Where the pointer came to rest on the point, and when (the frame clock). + rest: Option<(Pos2, f64)>, + /// Just switched back to lines: says so until the pointer leaves. + back_to_lines: bool, +} + +impl LineChain { + /// Curve `id` now ends the chain, with the pointer still on its end. + pub fn placed(&mut self, id: &str) { + *self = LineChain { curve: Some(id.to_string()), ..LineChain::default() }; + } +} + +/// Whether the tool is a Line tool's chain drawing tangent arcs, not the Tangent Arc tool itself. +pub fn in_arc_mode(t: &Tool) -> bool { + t.cmd == TANGENT_ARC && t.chain.curve.is_some() +} + +/// Whether the tool has a chain to switch: a segment drawn, and nothing typed for the next one. +fn can_switch(t: &Tool) -> bool { + (t.cmd == LINE || in_arc_mode(t)) && t.chain.curve.is_some() && t.pts.len() == 1 && !t.dims.iter().any(|b| b.locked) +} + +/// Per frame, with `pointer` free over the view (nothing held, no camera move): notes where it has +/// been against the chain's last point, and switches after a rest there. +pub fn follow_pointer(app: &mut SolveApp, ctx: &egui::Context, proj: &Proj, pointer: Option) { + if !app.preferences.line_hover_tangent_arc || !app.tool.as_ref().is_some_and(can_switch) { + return; + } + if crate::sketch3d::active(app) || app.dialog.is_some() || crate::dim_view::editing() { + return; + } + let screen = { + let state = app.session.world_state(); + let plane = app.session.active_sketch.and_then(|s| state.sketch(s)).map(|ss| ss.plane); + let last = app.tool.as_ref().and_then(|t| t.pts.first()).map(|p| p.0); + plane.zip(last).and_then(|(plane, last)| proj.to_screen(plane.to_world(last))) + }; + let now = ctx.input(|i| i.time); + let Some(chain) = app.tool.as_mut().map(|t| &mut t.chain) else { return }; + let (Some(p), Some(point)) = (pointer, screen) else { + chain.rest = None; + return; + }; + let off = p.distance(point); + if off >= AWAY { + chain.left = true; + chain.back_to_lines = false; + } + if off > ON_POINT || !chain.left { + chain.rest = None; + return; + } + match chain.rest { + Some((at, since)) if at.distance(p) <= STILL => { + if now - since >= REST_SECONDS { + switch(app); + } else { + ctx.request_repaint_after(std::time::Duration::from_secs_f64(REST_SECONDS - (now - since) + 0.01)); + } + } + _ => { + chain.rest = Some((p, now)); + ctx.request_repaint_after(std::time::Duration::from_secs_f64(REST_SECONDS + 0.01)); + } + } +} + +/// Lines to tangent arcs, or back. The chain's point and curve go across; the pointer has to leave +/// the point before it can switch again. +fn switch(app: &mut SolveApp) { + let Some(old) = app.tool.as_ref() else { return }; + let to_arc = old.cmd == LINE; + let next = if to_arc { crate::sketch_tools::tool_for(TANGENT_ARC) } else { Tool::for_command(LINE) }; + let Some(mut next) = next else { return }; + next.pts = old.pts.clone(); + next.hover = old.hover.clone(); + next.chain = LineChain { curve: old.chain.curve.clone(), back_to_lines: !to_arc, ..LineChain::default() }; + app.tool = Some(next); + let hint = if to_arc { app.tr("ui.line_arc_hint").to_string() } else { crate::tools::hint(LINE) }; + app.set_status(hint, false); +} + +/// Right-click or Esc while drawing tangent arcs: the chain ends as a line chain does, and the Line +/// tool stays out. False when the tool isn't that. +pub fn finish(app: &mut SolveApp) -> bool { + if !app.tool.as_ref().is_some_and(in_arc_mode) { + return false; + } + app.tool = Tool::for_command(LINE); + app.set_status(crate::tools::hint(LINE), false); + true +} + +/// A click while drawing tangent arcs: the arc, by the tangent arc command, tangent to the chain's +/// last curve, its end the chain's new last point. +pub fn place_arc(app: &mut SolveApp, tool: &mut Tool, end: &(Vec2, Option)) { + let (Some(start), Some(curve)) = (tool.pts.first().and_then(|p| p.1.clone()), tool.chain.curve.clone()) else { return }; + // On an existing point the arc ends on that point, so an outline closes. The other snaps + // (a midpoint, a place on a curve) name no point: their coordinates. + let on_point = |id: &&String| { + let state = app.session.world_state(); + app.session.active_sketch.and_then(|s| state.sketch(s)).is_some_and(|ss| ss.sketch.resolve_point(id).is_some()) + }; + let to = end.1.as_ref().filter(on_point).map_or_else(|| json!([end.0.x, end.0.y]), |id| json!(id)); + // A failure (the end straight ahead) has said why in the status line; the chain stays as it is. + let made = crate::tools::draw(app, tool, json!({"start": start, "curve": curve, "end": to})); + if let Some(id) = made.as_ref().ok().and_then(|v| v["curves"].get(0)).and_then(Value::as_str) { + tool.pts = vec![(end.0, Some(format!("{id}.end")))]; + tool.chain.placed(id); + } +} + +/// The arc a click would make with the pointer at `hover`, for the preview. +pub fn preview_arc(sketch: &Sketch, t: &Tool, hover: Vec2) -> Option { + let start = t.pts.first()?.1.as_deref()?; + solidcraft_engine::cmd::tangent_arc_preview(sketch, t.chain.curve.as_deref()?, start, hover) +} + +/// What the cursor says the next click makes (command, icon): while drawing tangent arcs, and just +/// after switching back to lines. +fn shown_mode(t: &Tool) -> Option<(&'static str, &'static str)> { + if in_arc_mode(t) { + Some((TANGENT_ARC, "arc_tangent")) + } else if t.cmd == LINE && t.chain.back_to_lines { + Some((LINE, "line")) + } else { + None + } +} + +/// The words next to the cursor, in the interface language. +pub(crate) fn shown_label(t: &Tool) -> Option<&'static str> { + shown_mode(t).map(|(command, _)| crate::i18n::tr_label(crate::command_label(command))) +} + +/// The label next to the cursor (below the point's coordinates). +pub fn paint_mode(app: &SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &Proj) { + let Some((command, icon)) = app.tool.as_ref().and_then(shown_mode) else { return }; + let Some(pointer) = ui.input(|i| i.pointer.hover_pos()).filter(|p| proj.rect.contains(*p)) else { return }; + let tk = crate::theme::Tokens::view(); + let Some(label) = app.tool.as_ref().and_then(shown_label) else { return }; + let galley = painter.layout_no_wrap(label.to_string(), egui::FontId::proportional(tk.font_small), tk.text); + let side = galley.size().y.max(14.0); + let at = pointer + egui::vec2(16.0, 40.0); + let icon_rect = egui::Rect::from_min_size(at, egui::vec2(side, side)); + let text_at = at + egui::vec2(side + 4.0, (side - galley.size().y) * 0.5); + let chip = egui::Rect::from_min_max(at, at + egui::vec2(side + 4.0 + galley.size().x, side)).expand(3.0); + painter.rect_filled(chip, tk.radius_sm, tk.panel); + painter.rect_stroke(chip, tk.radius_sm, Stroke::new(1.0, tk.sketch_accent), egui::StrokeKind::Inside); + crate::icons::paint_icon(painter, icon_rect, icon, 1.0); + painter.galley(text_at, galley, tk.text); + crate::scenario::publish_count("line_arc_mode_chip", if command == TANGENT_ARC { 1.0 } else { 2.0 }); +} diff --git a/crates/ui-egui/src/sketch_modify_ui.rs b/crates/ui-egui/src/sketch_modify_ui.rs index aa0c0a93fa..3317e32720 100644 --- a/crates/ui-egui/src/sketch_modify_ui.rs +++ b/crates/ui-egui/src/sketch_modify_ui.rs @@ -32,7 +32,7 @@ use solidcraft_engine::Sel; use solidcraft_engine::doc::Document; use solidcraft_engine::doc::expr::Kind as ValueKind; use solidcraft_engine::geom::{Vec2, Vec3}; -use solidcraft_engine::sketch::PatternAlignment; +use solidcraft_engine::sketch::{PatternAlignment, VariableUnit}; use crate::SolveApp; use crate::dialogs::{CHAMFER_TYPES, Dialog, Kind}; @@ -142,7 +142,7 @@ pub enum Mod { /// `sketch.equation_curve` (or `.edit` on the curve the window's `curve` extra names): the /// form (an index into [`EQUATION_FORMS`]), x(t) and y(t) (or y(x)), the variable's range and /// how far the curve may stray from the formulas. - Equation { form: usize, x: String, y: String, start: String, end: String, tolerance: String }, + Equation { form: usize, x: String, y: String, start: String, end: String, tolerance: String, range_unit: bool }, } impl Mod { @@ -204,11 +204,10 @@ fn inputs(k: &Mod) -> Vec { Mod::PatternCirc { .. } => { vec![SelInput::new("Entities", CURVES | SKETCH_POINTS | PROFILES, true), SelInput::new("Center", SKETCH_POINTS, false)] } - // A fillet rounds any number of corners to one radius (`points`). Each chamfer command - // cuts one corner: its point, or its two lines. Two lines name the corner between them - // where more lines meet at its point (#1824), for both. - Mod::Corner { chamfer: false, .. } => vec![SelInput::new("Corners", SKETCH_POINTS | CURVES, true)], - Mod::Corner { .. } => vec![SelInput::new("Corner", SKETCH_POINTS | CURVES, true)], + // A fillet rounds any number of corners to one radius, and a chamfer cuts any number + // alike (`points`). Two lines name the corner between them where more lines meet at its + // point (#1824), for both. + Mod::Corner { .. } => vec![SelInput::new("Corners", SKETCH_POINTS | CURVES, true)], Mod::Equation { .. } => Vec::new(), } } @@ -271,6 +270,7 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec)> { start: "0".into(), end: "2 * pi".into(), tolerance: "0.01 mm".into(), + range_unit: false, }, _ => return None, }; @@ -291,6 +291,7 @@ pub fn edit_equation(app: &mut SolveApp, id: &str) -> bool { start: eq.start, end: eq.end, tolerance: format!("{} mm", eq.tolerance), + range_unit: eq.variable_unit == VariableUnit::Range, }; let mut dialog = Dialog::new(Kind::SketchModify(kind), Vec::new()); dialog.extra.insert("curve".into(), json!(id)); @@ -584,7 +585,7 @@ pub fn rows(ui: &mut egui::Ui, k: &mut Mod, doc: &Document) -> bool { alignment(ui, "Alignment", a, reversed); pattern_hint(ui); } - Mod::Equation { form, x, y, start, end, tolerance } => { + Mod::Equation { form, x, y, start, end, tolerance, range_unit } => { ui_kit::choice_row(ui, "Form", "equation_form", form, &EQUATION_FORMS); if *form == 0 { enter |= number(ui, "x(t)", x); @@ -595,7 +596,9 @@ pub fn rows(ui: &mut egui::Ui, k: &mut Mod, doc: &Document) -> bool { enter |= number(ui, "Start", start); enter |= number(ui, "End", end); enter |= number(ui, "Tolerance", tolerance); - ui_kit::text(ui, crate::i18n::tr("ui.equation_note"), ui_kit::Text::Note("equation")); + ui_kit::checkbox_grid(ui, &mut [(crate::i18n::tr("ui.equation_variable_unit"), range_unit)], 1); + let note = if *range_unit { "ui.equation_note_range" } else { "ui.equation_note" }; + ui_kit::text(ui, crate::i18n::tr(note), ui_kit::Text::Note("equation")); } Mod::Corner { chamfer, ctype, size, distance2, angle, flip } => { if *chamfer { @@ -1046,11 +1049,15 @@ pub fn commands(app: &SolveApp, k: &Mod, inputs: &[SelInput], extra: &Map 0; let mut p = match (corner_ids(app, inputs, 0).as_slice(), line_ids(inputs, 0).as_slice()) { ([point], []) => corner_ref(app, point, flipped)?, + // Several corners are cut alike, each flipped as one alone would be. + (points, []) if !points.is_empty() => { + json!({"points": points.iter().map(|point| corner_ref(app, point, flipped)).collect::, _>>()?}) + } // Flip swaps the lines, so `distance` applies to the other leg. ([], [a, b]) if flipped => json!({"a": b, "b": a}), ([], [a, b]) => json!({"a": a, "b": b}), ([], []) => return Err("select the corner to cut first".into()), - _ => return Err("select one corner point, or the two lines of one corner".into()), + _ => return Err("select corner points, or the two lines of one corner".into()), }; p["distance"] = json!(corner_size(app, size_label(true, *ctype), size, ValueKind::Length)?); match ctype { @@ -1066,7 +1073,7 @@ pub fn commands(app: &SolveApp, k: &Mod, inputs: &[SelInput], extra: &Map ("sketch.chamfer.equal_distance", p), } } - Mod::Equation { form, x, y, start, end, tolerance } => { + Mod::Equation { form, x, y, start, end, tolerance, range_unit } => { let filled = |label: &str, text: &str| -> Result { let text = text.trim(); if text.is_empty() { Err(format!("{label} is empty")) } else { Ok(text.to_string()) } @@ -1081,6 +1088,7 @@ pub fn commands(app: &SolveApp, k: &Mod, inputs: &[SelInput], extra: &Map Harness { + let mut h = harness("sketch.line"); + h.app.preferences.line_hover_tangent_arc = on; + click_at(&mut h, [-20.0, 0.0]); + click_at(&mut h, [0.0, 0.0]); + assert_eq!(curves(&h), 1, "{:?}", h.app.status); + h +} + +fn click_at(h: &mut Harness, at: [f64; 2]) { + h.step(&json!({"move":{"sketch":at}})).unwrap(); + h.frames(2); + h.step(&json!({"click":{"sketch":at}})).unwrap(); + h.frames(2); +} + +fn tool(h: &Harness) -> Option<&'static str> { + h.app.tool.as_ref().map(|t| t.cmd) +} + +/// Move off the point, back onto it, and rest there for `frames` frames at 60 Hz. +fn leave_and_rest(h: &mut Harness, on: [f64; 2], frames: usize) { + h.step(&json!({"move":{"sketch":[on[0] + 15.0, on[1] + 15.0]}})).unwrap(); + h.frames(2); + h.step(&json!({"move":{"sketch":on}})).unwrap(); + h.frames(frames); +} + +/// A rest long enough for the switch: about half a second (it takes 0.35 s). +const REST: usize = 30; + +fn sketch(h: &Harness) -> solidcraft_engine::sketch::Sketch { + let sid = h.app.session.active_sketch.unwrap(); + h.app.session.model.state().sketch(sid).unwrap().sketch.clone() +} + +/// The arc last drawn: centre, radius and where it starts and ends as drawn. +fn last_arc( + sk: &solidcraft_engine::sketch::Sketch, +) -> (solidcraft_engine::geom::Vec2, f64, solidcraft_engine::geom::Vec2, solidcraft_engine::geom::Vec2) { + let (index, curve) = sk.curves.iter().enumerate().rfind(|(_, c)| matches!(c.kind, CurveKind::Arc { .. })).expect("an arc"); + let CurveKind::Arc { c, a, b } = curve.kind else { panic!("not an arc: {curve:?}") }; + let centre = sk.point(c).unwrap(); + // As drawn: a clockwise arc is stored the other way round. + let (from, to) = if curve.reversed { (b, a) } else { (a, b) }; + let (start, end) = (sk.point(from).unwrap(), sk.point(to).unwrap()); + assert!(index > 0, "the arc follows a line"); + (centre, centre.dist(start), start, end) +} + +#[test] +fn off_by_default_resting_on_the_last_point_does_nothing() { + assert!(!crate::prefs::Prefs::default().line_hover_tangent_arc, "off by default"); + let mut h = chain_with_a_line(false); + leave_and_rest(&mut h, [0.0, 0.0], REST * 2); + assert_eq!(tool(&h), Some("sketch.line"), "the tool stays the Line tool"); + assert_eq!(crate::scenario::count_of("line_arc_mode_chip"), None, "and nothing shows at the cursor"); + // The next click is a line, as ever. + click_at(&mut h, [0.0, 20.0]); + assert_eq!(curves(&h), 2); + assert!(sketch(&h).curves.iter().all(|c| matches!(c.kind, CurveKind::Line { .. }))); +} + +#[test] +fn a_rest_on_the_last_point_makes_the_next_click_a_tangent_arc_and_back_to_a_line() { + let mut h = chain_with_a_line(true); + leave_and_rest(&mut h, [0.0, 0.0], REST); + assert_eq!(tool(&h), Some("sketch.arc.tangent"), "{:?}", h.app.status); + assert_eq!(crate::scenario::count_of("line_arc_mode_chip"), Some(1.0), "the cursor says Tangent Arc"); + assert_eq!(h.app.tool.as_ref().and_then(crate::sketch_line_arc::shown_label), Some("Tangent Arc"), "in words, not a message id"); + + // The line runs along +X into (0, 0): the tangent arc to (10, 10) has its centre on the + // normal there, (0, 10), at radius 10, and leaves running along +X. + click_at(&mut h, [10.0, 10.0]); + assert_eq!(curves(&h), 2, "{:?}", h.app.status); + let sk = sketch(&h); + let (centre, radius, start, end) = last_arc(&sk); + assert!(centre.dist(solidcraft_engine::geom::Vec2::new(0.0, 10.0)) < 1e-6, "centre {centre:?}"); + assert!((radius - 10.0).abs() < 1e-6, "radius {radius}"); + assert!(start.dist(solidcraft_engine::geom::Vec2::ZERO) < 1e-6 && end.dist(solidcraft_engine::geom::Vec2::new(10.0, 10.0)) < 1e-6); + let (line, arc) = (sk.curves.first().unwrap(), sk.curves.get(1).unwrap()); + let shared = sk.resolve_point(&format!("{}.end", line.id)) == sk.resolve_point(&format!("{}.start", arc.id)); + assert!(shared, "the arc starts on the line's own end"); + assert!( + sk.constraints.iter().any(|c| matches!(c.kind, solidcraft_engine::sketch::ConstraintKind::Tangent { a: 0, b: 1 })), + "tangent to the line: {:?}", + sk.constraints + ); + assert_eq!(tool(&h), Some("sketch.arc.tangent"), "it keeps drawing arcs until switched back"); + + // The arc's end is the chain's point; the same gesture goes back to lines. + leave_and_rest(&mut h, [10.0, 10.0], REST); + assert_eq!(tool(&h), Some("sketch.line"), "{:?}", h.app.status); + assert_eq!(crate::scenario::count_of("line_arc_mode_chip"), Some(2.0), "the cursor says Line"); + assert_eq!(h.app.tool.as_ref().and_then(crate::sketch_line_arc::shown_label), Some("Line")); + click_at(&mut h, [10.0, 30.0]); + assert_eq!(curves(&h), 3, "{:?}", h.app.status); + let sk = sketch(&h); + let (arc, line) = (sk.curves.get(1).unwrap(), sk.curves.get(2).unwrap()); + assert!(matches!(line.kind, CurveKind::Line { .. })); + let shared = sk.resolve_point(&format!("{}.end", arc.id)) == sk.resolve_point(&format!("{}.start", line.id)); + assert!(shared, "the line starts on the arc's end"); + // And the chain goes on from there as lines. + click_at(&mut h, [30.0, 30.0]); + assert_eq!(curves(&h), 4); + assert_eq!(tool(&h), Some("sketch.line")); +} + +#[test] +fn a_rounded_corner_outline_closes_in_one_line_session() { + let mut h = harness("sketch.line"); + h.app.preferences.line_hover_tangent_arc = true; + click_at(&mut h, [-20.0, -20.0]); + click_at(&mut h, [10.0, -20.0]); + leave_and_rest(&mut h, [10.0, -20.0], REST); + click_at(&mut h, [20.0, -10.0]); + leave_and_rest(&mut h, [20.0, -10.0], REST); + click_at(&mut h, [20.0, 20.0]); + leave_and_rest(&mut h, [20.0, 20.0], REST); + click_at(&mut h, [10.0, 30.0]); + leave_and_rest(&mut h, [10.0, 30.0], REST); + click_at(&mut h, [-20.0, 30.0]); + leave_and_rest(&mut h, [-20.0, 30.0], REST); + click_at(&mut h, [-30.0, 20.0]); + leave_and_rest(&mut h, [-30.0, 20.0], REST); + click_at(&mut h, [-30.0, -10.0]); + leave_and_rest(&mut h, [-30.0, -10.0], REST); + // The last arc ends on the first line's start: the outline is closed. + click_at(&mut h, [-20.0, -20.0]); + let sk = sketch(&h); + assert_eq!(sk.curves.len(), 8, "{:?}", h.app.status); + let tangents = sk.constraints.iter().filter(|c| matches!(c.kind, solidcraft_engine::sketch::ConstraintKind::Tangent { .. })).count(); + assert!(tangents >= 4, "every arc is tangent to the line before it: {tangents}"); + let first = sk.resolve_point(&format!("{}.start", sk.curves[0].id)); + let last = sk.resolve_point(&format!("{}.end", sk.curves[7].id)); + assert_eq!(first, last, "the last arc ends on the first point"); +} + +#[test] +fn a_pointer_that_never_left_or_keeps_moving_or_rests_elsewhere_does_not_switch() { + // A pause straight after the click, on the point it placed. + let mut h = chain_with_a_line(true); + h.frames(REST * 3); + assert_eq!(tool(&h), Some("sketch.line"), "a pause on the placed point switches nothing"); + + // A little jitter there isn't leaving it either. + h.step(&json!({"move":{"sketch":[0.5, 0.5]}})).unwrap(); + h.frames(REST * 2); + assert_eq!(tool(&h), Some("sketch.line"), "a small wobble is not leaving"); + + // Resting somewhere else. + h.step(&json!({"move":{"sketch":[20.0, 20.0]}})).unwrap(); + h.frames(REST * 2); + assert_eq!(tool(&h), Some("sketch.line"), "a rest elsewhere"); + h.step(&json!({"move":{"sketch":[-20.0, 0.0]}})).unwrap(); + h.frames(REST * 2); + assert_eq!(tool(&h), Some("sketch.line"), "a rest on the line's other end"); + + // Over the point, never still: each move restarts the wait. + let p = h.call("ui.at", json!({"sketch":[0.0, 0.0]}))["result"].clone(); + let (x, y) = (p[0].as_f64().unwrap(), p[1].as_f64().unwrap()); + for i in 0..80 { + let d = if i % 2 == 0 { 3.0 } else { -3.0 }; + h.call("ui.move", json!({"x": x + d, "y": y})); + h.frames(1); + } + assert_eq!(tool(&h), Some("sketch.line"), "a moving pointer never switches"); + + // Too short a rest. + leave_and_rest(&mut h, [0.0, 0.0], 5); + assert_eq!(tool(&h), Some("sketch.line"), "a tenth of a second is not a rest"); + + // Before any segment there is no chain to switch. + let mut h = harness("sketch.line"); + h.app.preferences.line_hover_tangent_arc = true; + click_at(&mut h, [-20.0, 0.0]); + leave_and_rest(&mut h, [-20.0, 0.0], REST * 2); + assert_eq!(tool(&h), Some("sketch.line"), "the first point alone switches nothing"); + + // Nor is the Tangent Arc tool itself switched by resting on its start. + let mut h = harness("sketch.line"); + h.app.preferences.line_hover_tangent_arc = true; + click_at(&mut h, [-20.0, 0.0]); + click_at(&mut h, [0.0, 0.0]); + h.step(&json!({"key":"Escape"})).unwrap(); + h.step(&json!({"start":"sketch.arc.tangent"})).unwrap(); + click_at(&mut h, [0.0, 0.0]); + leave_and_rest(&mut h, [0.0, 0.0], REST * 2); + assert_eq!(tool(&h), Some("sketch.arc.tangent")); + assert!(h.app.tool.as_ref().is_some_and(|t| !crate::sketch_line_arc::in_arc_mode(t)), "that tool is no chain"); +} + +#[test] +fn staying_on_the_point_after_a_switch_does_not_switch_back() { + let mut h = chain_with_a_line(true); + leave_and_rest(&mut h, [0.0, 0.0], REST); + assert_eq!(tool(&h), Some("sketch.arc.tangent")); + h.frames(REST * 4); + assert_eq!(tool(&h), Some("sketch.arc.tangent"), "the pointer has to leave first"); +} + +#[test] +fn esc_and_right_click_end_a_chain_drawing_arcs_as_they_end_a_line_chain() { + for right_click in [false, true] { + let mut h = chain_with_a_line(true); + leave_and_rest(&mut h, [0.0, 0.0], REST); + click_at(&mut h, [10.0, 10.0]); + assert_eq!(tool(&h), Some("sketch.arc.tangent")); + if right_click { + let p = h.call("ui.at", json!({"sketch":[20.0, 20.0]}))["result"].clone(); + h.call("ui.click", json!({"x":p[0],"y":p[1],"button":"right"})); + } else { + h.step(&json!({"key":"Escape"})).unwrap(); + } + h.frames(2); + // The chain has ended and the Line tool is out, with no point placed. + let t = h.app.tool.as_ref().expect("the Line tool stays out"); + assert_eq!((t.cmd, t.pts.len()), ("sketch.line", 0), "right click: {right_click}"); + // A click starts a new chain: a separate line, and a rest needs a segment again. + click_at(&mut h, [30.0, 0.0]); + click_at(&mut h, [40.0, 0.0]); + assert_eq!(curves(&h), 3); + // Esc twice leaves the tool, as ever. + h.step(&json!({"key":"Escape"})).unwrap(); + h.step(&json!({"key":"Escape"})).unwrap(); + h.frames(2); + assert_eq!(tool(&h), None); + } +} + +#[test] +fn undo_takes_back_one_arc_as_it_takes_back_one_segment() { + let mut h = chain_with_a_line(true); + let undo_before = h.app.session.undo.len(); + leave_and_rest(&mut h, [0.0, 0.0], REST); + assert_eq!(h.app.session.undo.len(), undo_before, "switching changes nothing in the design"); + click_at(&mut h, [10.0, 10.0]); + assert_eq!(curves(&h), 2); + assert_eq!(h.app.session.undo.len(), undo_before + 1, "the arc is one undo step"); + h.app.run("edit.undo", json!({})).unwrap(); + h.frames(2); + assert_eq!(curves(&h), 1, "only the arc goes"); + assert_eq!(sketch(&h).curves.first().map(|c| matches!(c.kind, CurveKind::Line { .. })), Some(true)); + // As with a line undone mid-chain, the chain's point went with it: the next click is refused + // and changes nothing, and Esc ends the chain, leaving the Line tool ready for a new one. + click_at(&mut h, [20.0, 20.0]); + assert_eq!(curves(&h), 1, "{:?}", h.app.status); + h.step(&json!({"key":"Escape"})).unwrap(); + h.frames(2); + assert_eq!(h.app.tool.as_ref().map(|t| (t.cmd, t.pts.len())), Some(("sketch.line", 0))); + click_at(&mut h, [30.0, 0.0]); + click_at(&mut h, [40.0, 0.0]); + assert_eq!(curves(&h), 2, "{:?}", h.app.status); +} + +/// The preference saves, and the Sketch page's checkbox, Restore Defaults and Cancel treat it as +/// the page's own. +#[test] +fn the_tangent_arc_preference_saves_and_follows_the_sketch_page() { + use crate::prefs::control; + let mut h = Harness::new(); + h.call("ui.prefs", json!({"open": "Sketch"})); + h.frames(4); + crate::prefs::reveal_preference(&mut h, "prefs:line_hover_tangent_arc"); + h.step(&json!({"click":{"handle":"prefs:line_hover_tangent_arc"}})).unwrap(); + h.frames(2); + assert!(h.app.preferences.line_hover_tangent_arc, "the checkbox turns it on"); + control(&mut h.app, &json!({"button": "apply"})).unwrap(); + let mut other = crate::SolveApp::new(solidcraft_engine::Session::default(), Default::default()); + other.load_prefs(&h.app.prefs()); + assert!(other.preferences.line_hover_tangent_arc, "saved"); + control(&mut h.app, &json!({"button": "defaults"})).unwrap(); + assert!(!h.app.preferences.line_hover_tangent_arc); + control(&mut h.app, &json!({"button": "cancel"})).unwrap(); + assert!(h.app.preferences.line_hover_tangent_arc); +} + +#[test] +fn arcs_follow_one_another_each_tangent_to_the_last_and_a_pause_after_one_switches_nothing() { + let mut h = chain_with_a_line(true); + leave_and_rest(&mut h, [0.0, 0.0], REST); + click_at(&mut h, [10.0, 10.0]); + // The pointer is on the arc's end, where it was clicked: thinking there switches nothing. + h.frames(REST * 3); + assert_eq!(tool(&h), Some("sketch.arc.tangent"), "a pause on the placed point switches nothing"); + // The first arc arrives at (10, 10) running along +Y, so the next one, to (20, 20), turns the + // other way about (20, 10) at radius 10: an S. + click_at(&mut h, [20.0, 20.0]); + assert_eq!(curves(&h), 3, "{:?}", h.app.status); + let sk = sketch(&h); + let (centre, radius, start, end) = last_arc(&sk); + assert!(centre.dist(solidcraft_engine::geom::Vec2::new(20.0, 10.0)) < 1e-6, "centre {centre:?}"); + assert!((radius - 10.0).abs() < 1e-6, "radius {radius}"); + assert!( + start.dist(solidcraft_engine::geom::Vec2::new(10.0, 10.0)) < 1e-6 && end.dist(solidcraft_engine::geom::Vec2::new(20.0, 20.0)) < 1e-6, + "from {start:?} to {end:?}" + ); + assert!( + sk.constraints.iter().any(|c| matches!(c.kind, solidcraft_engine::sketch::ConstraintKind::Tangent { a: 1, b: 2 })), + "tangent to the arc before it: {:?}", + sk.constraints + ); + // After a clockwise arc the chain still goes on from its drawn end, as a line. + leave_and_rest(&mut h, [20.0, 20.0], REST); + assert_eq!(tool(&h), Some("sketch.line"), "{:?}", h.app.status); + click_at(&mut h, [20.0, 40.0]); + assert_eq!(curves(&h), 4, "{:?}", h.app.status); + let sk = sketch(&h); + let CurveKind::Line { a: line_start, .. } = sk.curves[3].kind else { panic!("a line: {:?}", sk.curves[3]) }; + let CurveKind::Arc { a: arc_end, .. } = sk.curves[2].kind else { panic!("an arc: {:?}", sk.curves[2]) }; + let joined = line_start == arc_end || sk.point(line_start).unwrap().dist(sk.point(arc_end).unwrap()) < 1e-6; + assert!(joined, "the line starts where the arc ends"); +} diff --git a/crates/ui-egui/src/sketch_palette.rs b/crates/ui-egui/src/sketch_palette.rs index 2596dee079..eb9c399fa3 100644 --- a/crates/ui-egui/src/sketch_palette.rs +++ b/crates/ui-egui/src/sketch_palette.rs @@ -113,7 +113,15 @@ pub fn apply_mode(app: &mut SolveApp, id: &str, result: &Value) { if m == 0 || !solidcraft_engine::find_command(id).is_some_and(|c| c.tab == "SKETCH" && c.panel == "CREATE") { return; } - let curves: Vec = result["curves"].as_array().into_iter().flatten().filter_map(|c| c.as_str().map(str::to_string)).collect(); + // Drawing reports its curves as `curves`; Project and the other link commands report them per + // link, `links[].curves` (#9945). + let links = result["links"].as_array().into_iter().flatten().map(|l| &l["curves"]); + let curves: Vec = std::iter::once(&result["curves"]) + .chain(links) + .filter_map(Value::as_array) + .flatten() + .filter_map(|c| c.as_str().map(str::to_string)) + .collect(); if curves.is_empty() { return; } @@ -662,6 +670,49 @@ mod tests { } } + /// Project gives the curves it makes the drawing line type, as drawing does (#9945): its + /// reply lists them per link, not as `curves`. A projected line becomes construction, + /// centerline or infinite; a projected circle, which cannot be either of the last two, becomes + /// construction; normal leaves both as they are. One undo takes the projection away whole. + #[test] + fn project_gives_its_curves_the_drawing_line_type() { + let flags = |c: &solidcraft_engine::sketch::Curve| (c.construction, c.centerline, c.infinite); + for (m, line, circle) in [ + (0, (false, false, false), (false, false, false)), + (1, (true, false, false), (true, false, false)), + (2, (false, true, false), (true, false, false)), + // An infinite line is construction too, as when it is drawn. + (3, (true, false, true), (true, false, false)), + ] { + let mut h = crate::scenario::Harness::new(); + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + h.app.run("sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[20,10]})).unwrap(); + h.app.run("sketch.circle.center", json!({"center":[40,0],"radius":5})).unwrap(); + h.app.run("sketch.finish", json!({})).unwrap(); + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + set_mode(&mut h.app, m); + let history = h.app.session.undo.len(); + let r = h.app.run("sketch.project", json!({"refs":[{"sketch":1,"curve":"l1"},{"sketch":1,"curve":"c1"}]})).unwrap(); + assert_eq!(r["links"].as_array().map(Vec::len), Some(2), "mode {m}: {r}"); + let state = h.app.session.world_state(); + let sketch = &state.sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch; + let kind = |line: bool| { + sketch + .curves + .iter() + .find(|c| matches!(c.kind, solidcraft_engine::sketch::CurveKind::Line { .. }) == line) + .map(flags) + .unwrap_or_else(|| panic!("mode {m}: a projected {}", if line { "line" } else { "circle" })) + }; + assert_eq!(kind(true), line, "mode {m}: the line"); + assert_eq!(kind(false), circle, "mode {m}: the circle"); + assert_eq!(h.app.session.undo.len(), history + 1, "mode {m}: one undo step"); + h.app.run("edit.undo", json!({})).unwrap(); + assert!(h.app.session.world_state().sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch.curves.is_empty(), "mode {m}"); + set_mode(&mut h.app, 0); + } + } + #[test] fn palette_footer_and_scrolled_lower_option_remain_reachable_at_scale_and_resize() { for (scale, height) in [(1.0, 900.0), (2.0, 900.0), (2.0, 720.0)] { @@ -701,6 +752,33 @@ mod tests { } } + /// The pane is placed from the viewport's right edge. At a fractional display scale (Windows + /// at 125 %) it hopped a physical pixel back and forth on every repaint the pointer caused, + /// most visibly docked against the window's edge (#9457). + #[test] + fn pane_stays_still_while_the_pointer_moves_at_fractional_scale() { + for layout in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] { + for (scale, width, height) in [(1.0, 1600.0, 1000.0), (1.25, 1536.0, 921.6), (1.25, 1522.4, 904.8), (1.5, 1280.0, 800.0)] { + let mut h = crate::scenario::Harness::new(); + h.app.ui.layout = layout; + h.call("ui.resize", json!({"width":width,"height":height})); + h.call("ui.prefs", json!({"ui_scale":scale})); + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + h.app.run("sketch.line", json!({"points":[[0,0],[30,0],[30,20]],"closed":false})).unwrap(); + h.app.start("sketch.line"); + h.settle(); + let mut seen = Vec::new(); + for i in 0..8 { + h.app.synthetic.push(egui::Event::PointerMoved(egui::pos2(400.0 + i as f32 * 7.3, 400.0 + i as f32 * 3.1))); + h.frame(); + let pane = h.app.viewport.palette_rect.expect("the pane is shown while sketching"); + seen.push((pane, crate::scenario::rect_of("sketch_palette:finish"))); + } + assert!(seen.windows(2).all(|w| w[0] == w[1]), "{layout:?} at scale {scale}, {width}x{height}: the pane moved: {seen:?}"); + } + } + } + /// A command dialog opened while sketching used to hide the palette outright. The two now /// share the viewport, the dialog at the left edge and the palette at the right, and a view /// too narrow for both keeps the dialog alone. diff --git a/crates/ui-egui/src/sketch_text.rs b/crates/ui-egui/src/sketch_text.rs index 9089df35e2..42c8174690 100644 --- a/crates/ui-egui/src/sketch_text.rs +++ b/crates/ui-egui/src/sketch_text.rs @@ -493,6 +493,13 @@ pub(crate) fn refresh(app: &SolveApp, k: &mut Tx) -> bool { true } +/// Whether the font (or built-in font when empty) has a bold face. +fn font_has_bold_face(name: &str) -> bool { + let trimmed = name.trim(); + let font = if trimmed.is_empty() { None } else { Some(trimmed) }; + solidcraft_engine::sketch::font_data_styled(font, true, false).is_ok() +} + /// The dialog's rows; true when Enter was pressed in a value field or in the text (Shift+Enter /// starts a new line there). `inputs` and `active`: the dialog's Path input. pub(crate) fn rows(ui: &mut egui::Ui, k: &mut Tx, inputs: &mut [SelInput], active: &mut usize) -> bool { @@ -511,8 +518,12 @@ pub(crate) fn rows(ui: &mut egui::Ui, k: &mut Tx, inputs: &mut [SelInput], activ state.store(ui.ctx(), id); k.focus = false; } + let font_has_bold = font_has_bold_face(&k.font); + if !font_has_bold && k.style.bold { + k.style.bold = false; + } ui_kit::row(ui, "Font", |ui| font_row(ui, k)); - ui_kit::row(ui, "Style", |ui| ui_kit::text_emphasis(ui, &mut k.style.bold, &mut k.style.italic)); + ui_kit::row(ui, "Style", |ui| ui_kit::text_emphasis(ui, &mut k.style.bold, &mut k.style.italic, font_has_bold)); ui_kit::row(ui, "Alignment", |ui| { let mut selected = match k.style.align { TextAlign::Left => 0, @@ -692,9 +703,10 @@ fn edit_params(app: &SolveApp, k: &Tx) -> Result { TextAlign::Center => "center", TextAlign::Right => "right", }; + let bold = k.style.bold && font_has_bold_face(&k.font); let mut params = json!({ "link": k.link, "at": [sent_x, sent_y], "height": sent_height, - "bold": k.style.bold, "italic": k.style.italic, "align": align, + "bold": bold, "italic": k.style.italic, "align": align, "letter_spacing": letter_spacing, "line_spacing": line_spacing, "path": k.style.path.clone().unwrap_or_default(), "reverse": k.style.reverse, }); @@ -1528,30 +1540,30 @@ mod tests { assert_eq!(tx(&h.app).values[5], "1.25"); } + /// #9940: built-in font has no bold face; Bold toggle is disabled so clicking it + /// does not set bold or trigger errors, and alignment and other options succeed. #[test] - fn bold_in_a_font_without_a_bold_face_is_refused_with_the_reason() { + fn bold_in_a_font_without_a_bold_face_is_disabled() { let (mut h, link, _) = setup(); let before = (h.app.session.doc.to_json(), h.app.session.undo.len()); h.app.start("sketch.edit_text"); h.frames(3); + assert!(!font_has_bold_face(&tx(&h.app).font)); + assert!(!tx(&h.app).style.bold); + // Clicking disabled bold does not toggle it, preview stays clean without errors. h.step(&json!({"click":{"handle":"dialog:Bold"}})).unwrap(); h.frames(2); - assert!(!style_of(&h, &link).bold, "the canvas keeps the last text it could make"); - let error = h.app.dialog.as_ref().unwrap().error.clone().unwrap_or_default(); - assert!(error.contains("no bold face"), "the preview must explain the unchanged text before OK: {error}"); - h.frames(2); - assert_eq!(h.app.dialog.as_ref().unwrap().error.as_deref(), Some(error.as_str()), "the reason stays visible on unchanged frames"); - h.step(&json!({"click":{"handle":"dialog:Bold"}})).unwrap(); - h.frames(2); - assert!(h.app.dialog.as_ref().unwrap().error.is_none(), "a valid preview clears the refusal"); - h.step(&json!({"click":{"handle":"dialog:Bold"}})).unwrap(); + assert!(!tx(&h.app).style.bold); + assert!(!style_of(&h, &link).bold); + assert!(h.app.dialog.as_ref().unwrap().error.is_none()); + // Changing alignment succeeds without error. + h.step(&json!({"click":{"handle":"dialog:Align center"}})).unwrap(); h.frames(2); + assert_eq!(tx(&h.app).style.align, TextAlign::Center); ok(&mut h); - assert!(editing(&h.app)); - let error = h.app.dialog.as_ref().unwrap().error.clone().unwrap_or_default(); - assert!(error.contains("no bold face"), "{error}"); - h.step(&json!({"widget":"Cancel"})).unwrap(); - assert_eq!((h.app.session.doc.to_json(), h.app.session.undo.len()), before); + assert!(!editing(&h.app)); + assert_eq!(style_of(&h, &link).align, TextAlign::Center); + assert_eq!(h.app.session.undo.len(), before.1 + 1); } #[test] diff --git a/crates/ui-egui/src/sketch_tools.rs b/crates/ui-egui/src/sketch_tools.rs index 1d1cd616f8..5d0a21cced 100644 --- a/crates/ui-egui/src/sketch_tools.rs +++ b/crates/ui-egui/src/sketch_tools.rs @@ -214,30 +214,6 @@ fn continuity_label(app: &SolveApp, seam: &Value) -> String { /// The Continuity Analysis legend in the viewport's lower left corner: each class in its /// colour, while seams are shaded. -fn continuity_legend(app: &SolveApp, painter: &egui::Painter, proj: &Proj) { - if app.session.continuity.is_empty() { - return; - } - let tk = crate::theme::Tokens::get(); - let [smooth, jump, sharp, gap] = tk.continuity; - // Stable ids: this Gap is a hole between faces, not the sheet-metal gap (`ui.sheet_gap`). - let tr = crate::i18n::tr; - let rows = [(tr("ui.continuity_g2"), smooth), (tr("ui.continuity_g1"), jump), (tr("ui.continuity_g0"), sharp), (tr("ui.continuity_gap"), gap)]; - let font = egui::FontId::proportional(tk.font_body); - let (row, pad, swatch) = (18.0, 8.0, 22.0); - let text_width = rows.iter().map(|(label, _)| painter.layout_no_wrap(label.to_string(), font.clone(), tk.text).size().x).fold(0.0, f32::max); - let size = egui::vec2(pad + swatch + 8.0 + text_width + pad, rows.len() as f32 * row + 2.0 * pad); - let available = crate::shell::bottom_overlay_rect(app, proj.rect); - let frame = egui::Rect::from_min_size(available.left_bottom() + egui::vec2(16.0, -(size.y + 16.0)), size); - painter.rect_filled(frame, tk.radius_sm, tk.panel.gamma_multiply(0.9)); - crate::scenario::publish_rect("continuity_legend", frame); - for (i, (label, col)) in rows.iter().enumerate() { - let y = frame.top() + pad + row * (i as f32 + 0.5); - painter.line_segment([egui::pos2(frame.left() + pad, y), egui::pos2(frame.left() + pad + swatch, y)], Stroke::new(3.0, *col)); - painter.text(egui::pos2(frame.left() + pad + swatch + 8.0, y), egui::Align2::LEFT_CENTER, *label, font.clone(), tk.text); - } -} - /// Isocurve analysis lines. fn iso_lines(app: &SolveApp, painter: &egui::Painter, proj: &Proj) { if app.tool.as_ref().map(|t| t.cmd) != Some("inspect.isocurve") { @@ -800,6 +776,7 @@ pub fn tool_for(id: &str) -> Option { dims_focus: false, arc_turn: 0.0, lined: Vec::new(), + chain: Default::default(), }) } @@ -1270,6 +1247,10 @@ pub(crate) fn place_point(app: &mut SolveApp, tool: &mut Tool, p: (Vec2, Option< } return; } + if crate::sketch_line_arc::in_arc_mode(tool) { + crate::sketch_line_arc::place_arc(app, tool, &p); + return; + } let Some(Mode::Points(n)) = mode(tool.cmd) else { return }; if let Err(error) = crate::sketch_dims::validate(app, tool) { app.set_status(error, true); @@ -1490,6 +1471,13 @@ pub fn preview(app: &SolveApp, t: &Tool, painter: &egui::Painter, proj: &Proj) { crate::tools::paint_sketch_path(&[ss.plane.to_world(first.0), hw], mode, painter, proj, faint); } crate::tools::paint_sketch_path(&[ss.plane.to_world(last.0), hw], mode, painter, proj, faint); + } else if crate::sketch_line_arc::in_arc_mode(t) { + // The Line tool switched to tangent arcs: the arc a click makes, or (straight ahead, + // where there is none) only a faint guide. + match crate::sketch_line_arc::preview_arc(&ss.sketch, t, *h) { + Some(arc) => crate::tools::paint_preview_curve(arc, &ss.plane, painter, proj, stroke, mode), + None => crate::tools::paint_sketch_path(&[ss.plane.to_world(last.0), hw], mode, painter, proj, faint), + } } else { crate::tools::paint_sketch_path(&[ss.plane.to_world(last.0), hw], mode, painter, proj, stroke); } @@ -1804,7 +1792,6 @@ pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &P } marks(app, painter, proj); iso_lines(app, painter, proj); - continuity_legend(app, painter, proj); } #[cfg(test)] diff --git a/crates/ui-egui/src/smart_extrude.rs b/crates/ui-egui/src/smart_extrude.rs index 8dd2259dc5..a5615b70ae 100644 --- a/crates/ui-egui/src/smart_extrude.rs +++ b/crates/ui-egui/src/smart_extrude.rs @@ -11,7 +11,9 @@ //! one that starts as a Join does not turn into a Cut when it reaches another body. When //! the distance passes through zero onto the other side (a drag reversed past the start, or //! Reverse), the operation is decided afresh for that side and latched there. So is a new -//! start (From Object, a start offset): it moves the extrude to other bodies (#467). A zero +//! start (From Object, a start offset): it moves the extrude to other bodies (#467), and +//! so is a body shown or hidden while the window is open (#9919). With no body shown it is a +//! New Body: there is nothing to join or cut. A zero //! distance changes nothing. Choosing an operation by hand ends the automatic choice for //! the rest of the extrude. @@ -31,6 +33,8 @@ pub struct Smart { pub target: Option, /// Where the extrude starts (its From Object and start offset) when the latch was decided. pub start: serde_json::Value, + /// The bodies shown when the latch was decided (None: every one), as `shown_scope` gives them. + pub shown: Option>, /// Picks off the profile plane become the To Object target (Preferences › Design). pub auto_to_object: bool, } diff --git a/crates/ui-egui/src/space_mouse.rs b/crates/ui-egui/src/space_mouse.rs index 3e8552fde6..7dd0a9145a 100644 --- a/crates/ui-egui/src/space_mouse.rs +++ b/crates/ui-egui/src/space_mouse.rs @@ -307,7 +307,7 @@ pub fn buttons_hook(app: &mut SolveApp, ctx: &egui::Context, seen: Buttons, raw: fn run_button_job(app: &mut SolveApp, job: &ButtonJob, modifiers: egui::Modifiers, raw: &mut egui::RawInput) { match job { // Navigation, where the 3D mouse's section is. - ButtonJob::Settings => crate::prefs::open_at(app, crate::prefs::PAGE_NAVIGATION), + ButtonJob::Settings => crate::prefs::open_at(app, crate::prefs::PAGE_MOUSE3D), ButtonJob::View(view) => app.animate_view(view), ButtonJob::Key(ButtonKey::Esc) => { for pressed in [true, false] { diff --git a/crates/ui-egui/src/space_mouse_offer.rs b/crates/ui-egui/src/space_mouse_offer.rs index 07013f06fb..1effc7b7db 100644 --- a/crates/ui-egui/src/space_mouse_offer.rs +++ b/crates/ui-egui/src/space_mouse_offer.rs @@ -222,7 +222,7 @@ pub fn act(app: &mut SolveApp, ctx: &egui::Context, offer: &Offer, action: Actio app.preferences.space_mouse_offered.insert(offer.device.clone()); if action == Action::Calibrate { // The calibration window lives with Preferences, at Navigation. - crate::prefs::open_at(app, crate::prefs::PAGE_NAVIGATION); + crate::prefs::open_at(app, crate::prefs::PAGE_MOUSE3D); crate::space_mouse_calibration::start(app); } } @@ -391,7 +391,7 @@ mod preferences_tests { h.app.space_mouse.device = Some("hid 046d:c62b".into()); h.frames(3); if open_first { - crate::prefs::open_at(&mut h.app, crate::prefs::PAGE_NAVIGATION); + crate::prefs::open_at(&mut h.app, crate::prefs::PAGE_MOUSE3D); } for _ in 0..frames_before { h.frame(); @@ -879,7 +879,7 @@ mod preferences_tests { h.app.preferences.space_mouse_calibrations.insert("hid 046d:c62b".into(), calibration); h.app.space_mouse.device = Some("hid 046d:c62b".into()); if prefs_open { - crate::prefs::open_at(&mut h.app, crate::prefs::PAGE_NAVIGATION); + crate::prefs::open_at(&mut h.app, crate::prefs::PAGE_MOUSE3D); } run(&mut h, 2.0); assert_eq!(crate::space_mouse::calibration(&h.app), Some(calibration)); @@ -918,7 +918,7 @@ mod preferences_tests { let mut h = spacenavd_harness(); run(&mut h, 2.0); assert!(card_up(&h)); - crate::prefs::open_at(&mut h.app, crate::prefs::PAGE_NAVIGATION); + crate::prefs::open_at(&mut h.app, crate::prefs::PAGE_MOUSE3D); run(&mut h, 3.0); assert!(!card_up(&h), "no card over Preferences"); h.app.prefs_window.open = false; diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index dfa7bb3aea..e6616aaa02 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -3,6 +3,7 @@ //! set any of their colours (`theme_files`). Every colour the UI uses comes from [`Tokens`]. use std::cell::Cell; +use std::collections::BTreeMap; #[path = "../../../assets/styles/ui/palettes.rs"] mod palettes; @@ -25,6 +26,7 @@ thread_local! { /// The palette of a theme file (`theme_files`) in place of the built-in one of its base /// (dark or light), per thread like the rest. `None`: the built-in palettes. static CUSTOM: Cell> = const { Cell::new(None) }; + static CUSTOM_GENERATION: Cell = const { Cell::new(0) }; /// The application bar's logo in the theme's accent instead of its own logo colours /// (Preferences › Interface › Logo), per thread like the rest. static LOGO_ACCENT: Cell = const { Cell::new(false) }; @@ -34,6 +36,298 @@ thread_local! { /// The 3D view's background colour when Preferences give it one (#7688), per thread like the /// rest. `None`: the theme's gradient. static VIEWPORT_BACKGROUND: Cell> = const { Cell::new(None) }; + /// The last published frame, guarded by every palette input so another app/context cannot + /// reuse it after changing the thread's theme. Resolution/fade state lives in that context. + static PUBLISHED: Cell> = const { Cell::new(None) }; +} + +/// Exact registry names, including contextual/specialty tabs and legacy Render sub-tabs that +/// still share the Render accent. Unknown names must never silently become Model on import. +pub(crate) const TAB_NAMES: &[&str] = &[ + "MODEL", + "SKETCH", + "SURFACE", + "MESH", + "FORM", + "SHEET METAL", + "WELDMENTS", + "ASSEMBLE", + "INSERT", + "INSPECT", + "MANAGE", + "VIEW", + "RENDER", + "RENDER APPEARANCE", + "RENDER SCENE", + "RENDER CAMERA", +]; + +#[derive(Clone, Copy, Debug, PartialEq)] +struct Environment { + dark: bool, + vision: ColorVision, + custom_generation: u64, + logo: bool, + viewport: Option, + background: Option<[u8; 3]>, +} + +impl Environment { + fn current() -> Self { + Self { + dark: is_dark(), + vision: vision(), + custom_generation: CUSTOM_GENERATION.get(), + logo: LOGO_ACCENT.get(), + viewport: VIEWPORT_DARK.get(), + background: viewport_background(), + } + } + + fn window(self, tokens: Tokens) -> Tokens { + let tokens = if self.logo { tokens.with_accent_logo() } else { tokens }; + tokens.with_vision(self.dark, self.vision) + } +} + +#[derive(Clone, Copy)] +struct PublishedTab { + environment: Environment, + tokens: Tokens, + view: Tokens, + tab_surfaces: bool, +} + +#[derive(Clone)] +struct PreparedTheme { + file: crate::theme_files::ThemeFile, + raw: Option>, + values: BTreeMap, + base: Custom, + enabled: bool, + fade_ms: f32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct Surfaces { + panel: Color32, + top: Color32, + bottom: Color32, +} + +impl Surfaces { + fn of(t: Tokens) -> Self { + Self { panel: t.panel, top: t.viewport_top, bottom: t.viewport_bottom } + } + + fn apply(self, t: &mut Tokens) { + t.panel = self.panel; + t.viewport_top = self.top; + t.viewport_bottom = self.bottom; + } + + fn blend(self, to: Self, progress: f32) -> Self { + // Return endpoints verbatim, including premultiplied alpha and byte rounding. + if progress <= 0.0 { + return self; + } + if progress >= 1.0 { + return to; + } + let eased = 1.0 - (1.0 - progress).powi(3); + Self { + panel: oklab_blend(self.panel, to.panel, eased), + top: oklab_blend(self.top, to.top, eased), + bottom: oklab_blend(self.bottom, to.bottom, eased), + } + } +} + +/// Interpolate unassociated sRGB in Oklab; alpha stays independent and is premultiplied once +/// by Color32 on output. This avoids dark fringes when an opacity provider is also enabled. +fn oklab_blend(from: Color32, to: Color32, fraction: f32) -> Color32 { + let lab = |color: Color32| { + let [r, g, b, a] = color.to_srgba_unmultiplied(); + let decode = solidcraft_engine::geom::srgb::decode8; + let (r, g, b) = (decode(r), decode(g), decode(b)); + let l = (0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b).cbrt(); + let m = (0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b).cbrt(); + let s = (0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b).cbrt(); + [ + 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s, + 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s, + 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s, + f64::from(a), + ] + }; + let (a, b) = (lab(from), lab(to)); + let mix = |i: usize| a[i] + (b[i] - a[i]) * f64::from(fraction); + let (light, a, b) = (mix(0), mix(1), mix(2)); + let l = (light + 0.3963377774 * a + 0.2158037573 * b).powi(3); + let m = (light - 0.1055613458 * a - 0.0638541728 * b).powi(3); + let s = (light - 0.0894841775 * a - 1.2914855480 * b).powi(3); + let encode = |linear: f64| { + let linear = linear.clamp(0.0, 1.0); + let srgb = if linear <= 0.0031308 { 12.92 * linear } else { 1.055 * linear.powf(1.0 / 2.4) - 0.055 }; + (srgb * 255.0).round() as u8 + }; + Color32::from_rgba_unmultiplied( + encode(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s), + encode(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s), + encode(-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s), + mix(3).round() as u8, + ) +} + +#[derive(Clone, Copy)] +struct Fade { + from: Surfaces, + to: Surfaces, + start: f64, + seconds: f64, +} + +impl Fade { + fn sample(self, now: f64) -> (Surfaces, bool) { + if !now.is_finite() || self.seconds <= 0.0 { + return (self.to, false); + } + let progress = ((now - self.start) / self.seconds).clamp(0.0, 1.0) as f32; + (self.from.blend(self.to, progress), progress < 1.0) + } +} + +#[derive(Clone, Default)] +struct TabThemeState { + prepared: Option, + environment: Option, + targets: BTreeMap, + tab: String, + shown: Option, + fade: Option, + /// Ownership of the two egui fills survives target/theme invalidation until restored. + styled_surfaces: bool, + #[cfg(test)] + resolutions: usize, +} + +#[derive(Clone, Copy)] +struct TabTarget { + tokens: Tokens, + view: Tokens, + accent: TabAccent, +} + +const TAB_STATE: &str = "sc_tab_theme_state"; + +/// Cache the authored theme and raw saved values before any effective-value normalization. +/// Followed-file reloads use full content equality, including edits that keep the same id/base. +/// The caller supplies its existing normalization contract only on a cache miss. +pub(crate) fn prepare_tabs( + ctx: &egui::Context, + file: Option<&crate::theme_files::ThemeFile>, + raw: Option<&BTreeMap>, + values: impl FnOnce() -> BTreeMap, +) -> Option { + ctx.data_mut(|data| { + let state = data.get_temp_mut_or_default::(egui::Id::new(TAB_STATE)); + let same = match (&state.prepared, file) { + (Some(old), Some(file)) => old.file == *file && old.raw.as_ref() == raw, + (None, None) => true, + _ => false, + }; + if !same { + state.prepared = file.map(|file| { + let values = values(); + let (enabled, fade_ms) = crate::theme_options::tab_settings(file, &values); + let base = Custom { dark: file.base.is_dark(), tokens: crate::theme_files::resolve_with(file, &values) }; + PreparedTheme { file: file.clone(), raw: raw.cloned(), values, base, enabled, fade_ms } + }); + state.environment = None; + state.targets.clear(); + // A theme/option change applies immediately; only tab changes animate. + state.shown = None; + state.fade = None; + PUBLISHED.set(None); + } + state.prepared.as_ref().map(|p| p.base) + }) +} + +impl TabThemeState { + fn publish(&mut self, tab: &str, now: f64) -> (PublishedTab, TabAccent, bool) { + let environment = Environment::current(); + if self.environment != Some(environment) { + self.environment = Some(environment); + self.targets.clear(); + self.shown = None; + self.fade = None; + } + // Bound the cache even if an internal caller supplies an unknown tab name. + let tab = if TAB_NAMES.contains(&tab) { tab } else { "MODEL" }; + // Borrow the key on resting frames; own a name only when caching a new target. + let target = if let Some(target) = self.targets.get(tab) { + *target + } else { + #[cfg(test)] + { + self.resolutions += 1; + } + let window = if let Some(prepared) = &self.prepared { + environment.window(crate::theme_files::resolve_for_tab(&prepared.file, &prepared.values, Some(tab))) + } else { + Tokens::themed(environment.dark, environment.vision) + }; + let accent = TabAccent::for_tab(&window, tab); + let window = if viewport_dark() == is_dark() { window } else { window.with_viewport(Tokens::themed(viewport_dark(), vision())) }; + let target = TabTarget { + tokens: window.with_background(environment.background), + view: Tokens::themed(viewport_dark(), vision()).with_background(environment.background), + accent, + }; + self.targets.insert(tab.to_owned(), target); + target + }; + let destination = Surfaces::of(target.tokens); + // Sample the old fade before retargeting, even when two tab changes happen in one frame. + if let Some(fade) = self.fade { + let (shown, running) = fade.sample(now); + let mut current = target.tokens; + shown.apply(&mut current); + if running { + current.protect_tab_panel(); + } + self.shown = Some(Surfaces::of(current)); + if !running { + self.fade = None; + } + } + if self.tab != tab || self.shown.is_none() { + let duration = self.prepared.as_ref().filter(|p| p.enabled).map_or(0.0, |p| p.fade_ms); + self.fade = self.shown.filter(|from| *from != destination && duration > 0.0 && now.is_finite()).map(|from| Fade { + from, + to: destination, + start: now, + seconds: f64::from(duration) / 1000.0, + }); + tab.clone_into(&mut self.tab); + if self.fade.is_none() { + self.shown = Some(destination); + } + } + let mut tokens = target.tokens; + self.shown.unwrap_or(destination).apply(&mut tokens); + // Protect interpolated alpha too; opacity's readability floor applies throughout a fade. + if self.fade.is_some() { + tokens.protect_tab_panel(); + } + self.shown = Some(Surfaces::of(tokens)); + let mut view = if viewport_dark() == is_dark() { tokens } else { target.view }; + view.viewport_top = tokens.viewport_top; + view.viewport_bottom = tokens.viewport_bottom; + let tab_surfaces = self.prepared.as_ref().is_some_and(|p| p.enabled && !p.file.tabs.is_empty()); + (PublishedTab { environment, tokens, view, tab_surfaces }, target.accent, self.fade.is_some()) + } } /// Three structural border levels plus independent content and control outlines. @@ -233,7 +527,11 @@ pub struct Custom { /// Use a theme file's palette for its base, or the built-in palettes (`None`). Returns whether /// that changed anything (the visuals follow on the next [`apply`]). pub fn set_custom(custom: Option) -> bool { - CUSTOM.with(|c| c.replace(custom) != custom) + let changed = CUSTOM.with(|c| c.replace(custom) != custom); + if changed { + CUSTOM_GENERATION.with(|generation| generation.set(generation.get().wrapping_add(1))); + } + changed } /// Draw the logo in the theme's accent (`true`) or its own logo colours. Returns whether that @@ -286,6 +584,39 @@ pub(crate) fn contrast_ratio(a: Color32, b: Color32) -> f64 { (a.max(b) + 0.05) / (a.min(b) + 0.05) } +/// Composite a translucent foreground over an opaque background in sRGB for contrast checks. +pub(crate) fn composite(fill: Color32, background: Color32) -> Color32 { + let rgb = fill.to_srgba_unmultiplied(); + let a = f64::from(rgb[3]) / 255.0; + let channel = |index: usize| (f64::from(rgb[index]) * a + f64::from(background[index]) * (1.0 - a)).round() as u8; + Color32::from_rgb(channel(0), channel(1), channel(2)) +} + +fn readable_fill(fill: Color32, inks: &[Color32], scale: f32) -> Color32 { + let readable = |candidate| { + [Color32::BLACK, Color32::WHITE].iter().all(|background| { + let ground = composite(candidate, *background); + inks.iter().all(|ink| contrast_ratio(composite(*ink, ground), ground) >= 4.5) + }) + }; + // A sampled/retargeted colour that already reads must stay bit-exact, including alpha. + if scale >= 1.0 && readable(fill) { + return fill; + } + let [r, g, b, alpha] = fill.to_srgba_unmultiplied(); + let requested = (f32::from(alpha) * scale).round() as u8; + // At most 256 cheap colour comparisons per changed role; no allocations, GPU or I/O. + for a in requested..=255 { + let candidate = Color32::from_rgba_unmultiplied(r, g, b, a); + if readable(candidate) { + return candidate; + } + } + // An authored foreground may already fail at full opacity. Never alter that foreground; + // full alpha gives it the strongest protection this option can supply. + Color32::from_rgb(r, g, b) +} + impl TabAccent { pub fn for_tab(t: &Tokens, tab: &str) -> Self { let (color, ink, soft) = match tab { @@ -334,7 +665,30 @@ impl TabAccent { } pub fn publish(app: &crate::SolveApp, ctx: &egui::Context) { - let accent = Self::for_tab(&Tokens::get(), &app.ui.tab); + let now = ctx.input(|i| i.time); + let (published, accent, fading, style_surfaces) = ctx.data_mut(|data| { + let state = data.get_temp_mut_or_default::(egui::Id::new(TAB_STATE)); + let (published, accent, fading) = state.publish(&app.ui.tab, now); + let style_surfaces = published.tab_surfaces || state.styled_surfaces; + state.styled_surfaces = published.tab_surfaces; + (published, accent, fading, style_surfaces) + }); + PUBLISHED.set(Some(published)); + if style_surfaces { + let fills = { + let style = ctx.global_style(); + (style.visuals.panel_fill, style.visuals.window_fill) + }; + if fills != (published.tokens.panel, published.tokens.panel) { + ctx.global_style_mut(|style| { + style.visuals.panel_fill = published.tokens.panel; + style.visuals.window_fill = published.tokens.panel; + }); + } + } + if fading { + ctx.request_repaint(); + } ctx.data_mut(|d| { d.insert_temp(egui::Id::new("sc_tab_accent"), accent); d.insert_temp(egui::Id::new("sc_tab_icon_accent"), icon_accent_for_tab(&app.ui.tab)); @@ -370,6 +724,12 @@ pub fn viewport_background() -> Option<[u8; 3]> { VIEWPORT_BACKGROUND.get() } +/// Whether the published theme opts into per-tab surfaces. Render's unauthored preview uses +/// this cached policy; it must retain its existing scene backplate when the option is absent/off. +pub(crate) fn tab_surfaces_enabled() -> bool { + PUBLISHED.get().is_some_and(|p| p.environment == Environment::current() && p.tab_surfaces) +} + /// The common conversion boundary for compiled icon palette data. pub(crate) const fn icon_color(rgb: [u8; 3]) -> Color32 { Color32::from_rgb(rgb[0], rgb[1], rgb[2]) @@ -886,6 +1246,23 @@ impl Tokens { /// [`Tokens::get`] in another colour vision mode: the Colour vision preference previews the /// mode being chosen before it is applied (#7152). pub fn get_in(mode: ColorVision) -> Tokens { + if let Some(published) = PUBLISHED.get().filter(|p| p.environment == Environment::current()) { + if mode == published.environment.vision { + return published.tokens; + } + let mut tokens = Self::base_in(mode); + Surfaces::of(published.tokens).apply(&mut tokens); + return tokens; + } + Self::base_in(mode) + } + + /// Unanimated colours for geometry cache keys: fading backgrounds must not rebuild bodies. + pub(crate) fn base() -> Tokens { + Self::base_in(vision()) + } + + fn base_in(mode: ColorVision) -> Tokens { let window = Tokens::themed(is_dark(), mode); let tokens = if viewport_dark() == is_dark() { window } else { window.with_viewport(Tokens::themed(viewport_dark(), mode)) }; tokens.with_background(viewport_background()) @@ -895,9 +1272,31 @@ impl Tokens { /// with nothing behind it (a status line, a label or a warning beside the geometry), so it /// reads on the view's background. As [`Tokens::get`] unless the view has its own side. pub fn view() -> Tokens { + if let Some(published) = PUBLISHED.get().filter(|p| p.environment == Environment::current()) { + return published.view; + } Tokens::themed(viewport_dark(), vision()).with_background(viewport_background()) } + pub(crate) fn protect_tab_panel(&mut self) { + self.panel = readable_fill(self.panel, &[self.text, self.text_dim, self.form_ink, self.dialog_label], 1.0); + } + + /// Scale only floating surface fill alpha, preserving full-opacity tokens exactly. + /// Raise each fill's floor to protect text over both black and white viewport extremes. + pub(crate) fn apply_panel_opacity(&mut self, percent: f32) { + if !percent.is_finite() || percent >= 100.0 { + return; + } + let scale = percent.clamp(40.0, 100.0) / 100.0; + let body_inks = [self.text, self.form_ink, self.dialog_label]; + for fill in [&mut self.panel, &mut self.panel_header, &mut self.dialog_bg, &mut self.dialog_body, &mut self.dialog_footer, &mut self.overlay] + { + *fill = readable_fill(*fill, &body_inks, scale); + } + self.nav_tip = readable_fill(self.nav_tip, &[self.nav_tip_ink], scale); + } + /// These tokens with the 3D view's background one flat colour (#7688), or as they are for `None`. pub fn with_background(mut self, colour: Option<[u8; 3]>) -> Tokens { if let Some([r, g, b]) = colour { @@ -1095,6 +1494,188 @@ pub fn apply(ctx: &egui::Context) { #[cfg(test)] mod tests { + use super::*; + + fn tabs_state(base: &str, overrides: BTreeMap) -> TabThemeState { + let file = crate::theme_files::tab_fixture(base); + let values = crate::theme_files::effective_values(&file, &overrides).0; + let (enabled, fade_ms) = crate::theme_options::tab_settings(&file, &values); + let base = Custom { dark: file.base.is_dark(), tokens: crate::theme_files::resolve_with(&file, &values) }; + set_dark(base.dark); + set_custom(Some(base)); + set_vision(ColorVision::Off); + set_viewport_dark(None); + set_viewport_background(None); + TabThemeState { prepared: Some(PreparedTheme { file, values, raw: None, base, enabled, fade_ms }), ..Default::default() } + } + + #[test] + fn tabs_fade_has_exact_endpoints_oklab_cubic_midpoint_and_independent_alpha() { + let from = Surfaces { panel: Color32::BLACK, top: Color32::BLACK, bottom: Color32::BLACK }; + let to = Surfaces { panel: Color32::WHITE, top: Color32::WHITE, bottom: Color32::WHITE }; + assert_eq!(from.blend(to, 0.0), from); + assert_eq!(from.blend(to, 1.0), to); + // Neutral Oklab L at half-time is 1 - (1 - .5)^3 = .875. The independent + // sRGB encoding of .875^3 is .8375, or byte 214 (gamma blending would give 223). + assert_eq!(from.blend(to, 0.5), Surfaces { panel: Color32::from_gray(214), top: Color32::from_gray(214), bottom: Color32::from_gray(214) }); + let a = Color32::from_rgba_unmultiplied(40, 90, 130, 128); + let b = Color32::from_rgba_unmultiplied(90, 150, 190, 64); + assert_eq!(oklab_blend(a, b, 0.875).a(), 72); + let fade = Fade { from, to, start: 1.0, seconds: 0.24 }; + assert_eq!(fade.sample(1.0), (from, true)); + assert_eq!(fade.sample(1.24), (to, false)); + assert_eq!(Fade { seconds: 0.0, ..fade }.sample(1.0), (to, false)); + assert_eq!(fade.sample(f64::NAN), (to, false)); + } + + #[test] + fn tabs_fade_retargets_from_displayed_surfaces_and_stops_resolving_at_rest() { + for opacity in [40.0, 100.0] { + let mut state = tabs_state("dark", BTreeMap::from([("opacity".into(), crate::theme_files::OptionValue::Num(opacity))])); + let (model, _, repaint) = state.publish("MODEL", 0.0); + assert!(!repaint); + let (start, _, repaint) = state.publish("SKETCH", 1.0); + assert!(repaint); + assert_eq!(Surfaces::of(start.tokens), Surfaces::of(model.tokens)); + let (middle, _, _) = state.publish("SKETCH", 1.12); + let (retarget, _, repaint) = state.publish("RENDER", 1.12); + assert!(repaint); + assert_eq!(Surfaces::of(retarget.tokens), Surfaces::of(middle.tokens)); + assert_eq!(retarget.view.viewport_top, retarget.tokens.viewport_top); + assert_eq!(retarget.view, retarget.tokens); + let (end, _, repaint) = state.publish("RENDER", 1.36); + assert!(!repaint); + assert_eq!(state.resolutions, 3); + for frame in 0..20 { + let (resting, _, repaint) = state.publish("RENDER", 2.0 + f64::from(frame)); + assert!(!repaint); + assert_eq!(resting.tokens, end.tokens); + } + assert_eq!(state.resolutions, 3, "resting frames must hit the resolved-token cache"); + state.publish("MODEL", 30.0); + assert_eq!(state.resolutions, 3, "returning to a tab reuses its target"); + } + } + + #[test] + fn tabs_panel_text_contrast_holds_at_five_fade_steps_with_opacity() { + use crate::theme_files::OptionValue; + for base in ["dark", "light"] { + for percent in [40.0, 75.0, 100.0] { + for mode in ColorVision::ALL { + for destination in ["SKETCH", "RENDER", "MODEL"] { + let mut state = tabs_state(base, BTreeMap::from([("opacity".into(), OptionValue::Num(percent))])); + set_vision(mode); + state.publish(if destination == "MODEL" { "RENDER" } else { "MODEL" }, 0.0); + state.publish(destination, 1.0); + for step in [0.0, 0.25, 0.5, 0.75, 1.0] { + let (frame, _, _) = state.publish(destination, 1.0 + step * 0.24); + let t = frame.tokens; + for behind in [Color32::BLACK, Color32::WHITE] { + let ground = composite(t.panel, behind); + for ink in [t.text, t.text_dim, t.form_ink, t.dialog_label] { + assert!( + contrast_ratio(composite(ink, ground), ground) >= 4.5, + "{base} {mode:?} opacity {percent} tab {destination} step {step}" + ); + } + } + assert_eq!((frame.view.viewport_top, frame.view.viewport_bottom), (t.viewport_top, t.viewport_bottom)); + } + } + } + } + } + } + + #[test] + fn tabs_cache_invalidates_for_theme_values_vision_and_viewport_overrides() { + let ctx = egui::Context::default(); + let mut file = crate::theme_files::tab_fixture("dark"); + let normalizations = Cell::new(0); + let prepare = |file: &crate::theme_files::ThemeFile, raw: Option<&BTreeMap>| { + let custom = prepare_tabs(&ctx, Some(file), raw, || { + normalizations.set(normalizations.get() + 1); + let saved = raw.into_iter().flatten().map(|(k, v)| (k.clone(), serde_json::from_value(v.clone()).unwrap())).collect(); + crate::theme_files::effective_values(file, &saved).0 + }); + set_dark(file.base.is_dark()); + set_custom(custom); + }; + let publish = || ctx.data_mut(|d| d.get_temp_mut_or_default::(egui::Id::new(TAB_STATE)).publish("SKETCH", 1.0)); + set_vision(ColorVision::Off); + set_viewport_dark(None); + set_viewport_background(None); + prepare(&file, None); + let before = publish().0.tokens; + prepare(&file, None); + assert_eq!(publish().0.tokens, before); + assert_eq!(normalizations.get(), 1); + // The content changes while identity/base remain the same, as with a followed reload. + file.tabs.get_mut("SKETCH").unwrap().insert("viewport_top".into(), serde_json::json!("#1a4232")); + prepare(&file, None); + assert_ne!(publish().0.tokens.viewport_top, before.viewport_top); + assert_eq!(normalizations.get(), 2); + set_vision(ColorVision::Tritan); + assert_eq!(publish().0.tokens.axis_x, before.with_vision(true, ColorVision::Tritan).axis_x); + set_viewport_dark(Some(false)); + assert_eq!(publish().0.tokens.viewport_top, Tokens::light().viewport_top); + set_viewport_background(Some([3, 7, 11])); + assert_eq!(publish().0.tokens.viewport_top, Color32::from_rgb(3, 7, 11)); + set_viewport_background(None); + set_viewport_dark(None); + let raw = BTreeMap::from([("tab_colours".into(), serde_json::json!(false))]); + prepare(&file, Some(&raw)); + assert_eq!(publish().0.tokens, Tokens::base()); + assert_eq!(normalizations.get(), 3); + } + + #[test] + fn tabs_publish_requests_repaints_only_during_fade_and_zero_is_instant() { + use crate::theme_files::OptionValue; + let mut app = crate::SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); + let ctx = crate::fonts::test_context(); + let mut state = tabs_state("dark", BTreeMap::new()); + state.publish("MODEL", 0.0); + ctx.data_mut(|d| d.insert_temp(egui::Id::new(TAB_STATE), state)); + app.ui.tab = "SKETCH".into(); + let frame = |time| { + let mut out = ctx.run_ui(egui::RawInput { time: Some(time), ..Default::default() }, |ui| { + TabAccent::publish(&app, ui.ctx()); + crate::gpu::paint_background(ui.painter(), ui.max_rect(), &Tokens::get()); + }); + let colors: Vec<_> = out + .shapes + .iter() + .find_map(|shape| match &shape.shape { + egui::Shape::Mesh(mesh) => Some(mesh.vertices.iter().map(|vertex| vertex.color).collect()), + _ => None, + }) + .unwrap(); + let tokens = Tokens::get(); + assert_eq!(colors, [tokens.viewport_top, tokens.viewport_top, tokens.viewport_bottom, tokens.viewport_bottom]); + out.textures_delta.clear(); + out.viewport_output[&egui::ViewportId::ROOT].repaint_delay + }; + assert!(frame(1.0).is_zero()); + assert!(frame(1.12).is_zero()); + // Drain the previously requested pass, then verify the endpoint leaves the loop asleep. + for time in [1.24, 1.3, 1.4] { + frame(time); + } + assert_eq!(frame(2.0), std::time::Duration::MAX); + let (end, _, _) = ctx.data_mut(|d| d.get_temp_mut_or_default::(egui::Id::new(TAB_STATE)).publish("SKETCH", 2.0)); + assert_eq!(Tokens::get(), end.tokens); + let mut instant = tabs_state("dark", BTreeMap::from([("tab_fade_ms".into(), OptionValue::Num(0.0))])); + instant.publish("MODEL", 0.0); + let (frame, _, repaint) = instant.publish("SKETCH", 1.0); + assert!(!repaint); + assert_eq!( + frame.tokens, + crate::theme_files::resolve_for_tab(&instant.prepared.as_ref().unwrap().file, &instant.prepared.as_ref().unwrap().values, Some("SKETCH")) + ); + } + #[test] fn widget_states_and_popup_frames_follow_theme_radii_without_changing_defaults() { use super::*; @@ -1299,6 +1880,69 @@ mod tests { assert_eq!(super::TabAccent::current_icon_accent(&first), None); } + #[test] + fn tabs_surface_publication_restores_base_fills_without_restyling_widgets() { + use crate::theme_files::{OptionValue, ThemeFile}; + for base in ["dark", "light"] { + let ctx = crate::fonts::test_context(); + let mut app = crate::SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); + let file = crate::theme_files::tab_fixture(base); + set_dark(file.base.is_dark()); + set_vision(ColorVision::Off); + set_viewport_dark(None); + set_viewport_background(None); + set_custom(None); + let prepare = |file: &ThemeFile, enabled: Option| { + let raw = enabled.map(|enabled| BTreeMap::from([("tab_colours".into(), serde_json::json!(enabled))])); + let custom = prepare_tabs(&ctx, Some(file), raw.as_ref(), || { + let saved = enabled.map(|enabled| BTreeMap::from([("tab_colours".into(), OptionValue::Bool(enabled))])).unwrap_or_default(); + crate::theme_files::effective_values(file, &saved).0 + }); + set_custom(custom) + }; + let publish = |app: &crate::SolveApp, time| { + ctx.run_ui(egui::RawInput { time: Some(time), ..Default::default() }, |ui| TabAccent::publish(app, ui.ctx())).textures_delta.clear(); + }; + prepare(&file, Some(true)); + apply(&ctx); + let before = ctx.global_style().visuals.clone(); + let base_custom = CUSTOM.get(); + app.ui.tab = "MODEL".into(); + publish(&app, 0.0); + assert_eq!(ctx.global_style().visuals, before); + app.ui.tab = "SKETCH".into(); + publish(&app, 1.0); + publish(&app, 1.12); + let panel = Tokens::get().panel; + assert_ne!(panel, before.panel_fill, "exercise an actual animated panel fill"); + let mut animated = before.clone(); + animated.panel_fill = panel; + animated.window_fill = panel; + assert_eq!(ctx.global_style().visuals, animated, "all non-surface widget/selection/status visuals stay unchanged"); + // Repair either owned fill independently, even when the panel already matches. + ctx.global_style_mut(|style| style.visuals.window_fill = before.window_fill); + publish(&app, 1.12); + assert_eq!(ctx.global_style().visuals, animated); + + assert!(!prepare(&file, Some(false)), "toggling surfaces does not change the base Custom tokens"); + assert_eq!(CUSTOM.get(), base_custom); + // Deliberately omit apply(): a full restyle would conceal stale animated fills. + publish(&app, 1.13); + assert_eq!(ctx.global_style().visuals, before, "turning surfaces off must restore both base fills"); + + assert!(!prepare(&file, Some(true))); + publish(&app, 2.0); + assert_ne!(ctx.global_style().visuals.panel_fill, before.panel_fill); + let mut plain = file.clone(); + plain.tabs.clear(); + plain.options.clear(); + assert!(!prepare(&plain, None), "a no-option theme can retain the exact same base tokens"); + assert_eq!(CUSTOM.get(), base_custom); + publish(&app, 2.1); + assert_eq!(ctx.global_style().visuals, before, "restoring a no-option theme must release the surface fills"); + } + } + #[test] fn concurrent_ui_renderers_keep_their_own_theme_and_vision() { let barrier = std::sync::Barrier::new(2); diff --git a/crates/ui-egui/src/theme_files.rs b/crates/ui-egui/src/theme_files.rs index 871db47332..fda8da4715 100644 --- a/crates/ui-egui/src/theme_files.rs +++ b/crates/ui-egui/src/theme_files.rs @@ -68,9 +68,9 @@ impl Base { } } -/// A theme: its id (the `theme` preference's value), name, base, optional radii and colour overrides, each a -/// `#rrggbb` / `#rrggbbaa` string or a list of them, keyed by [`keys`]. -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +/// A theme: identity, base value tables and optional declarations selecting literal overlays. +/// Colour overrides are `#rrggbb` / `#rrggbbaa` strings or lists, keyed by [`keys`]. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct ThemeFile { pub id: String, pub name: String, @@ -85,6 +85,164 @@ pub struct ThemeFile { /// SVG display roles and three-colour semantic ramps; omitted values keep compiled defaults. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub icon_palette: BTreeMap, + /// Optional surface overrides for known ribbon tabs. Accents retain their existing tokens. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub tabs: BTreeMap>, + /// Theme-owned declarations, in file order; standard ids are normalized on import. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub options: Vec, + /// Conditional value tables, applied in file order with later entries winning by key. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub overlays: Vec, + /// Levels newer than [`REQUIREMENTS_LEVEL`] disable options and overlays. + #[serde(default = "requirements_level", skip_serializing_if = "is_current_level")] + pub requires: u64, +} + +/// Theme option/overlay format level understood by this app. +pub const REQUIREMENTS_LEVEL: u64 = 1; +pub const MAX_OPTIONS: usize = 16; +pub const MAX_OVERLAYS: usize = 32; +const STANDARD_OPTIONS: [&str; 4] = ["opacity", "blur", "tab_colours", "tab_fade_ms"]; + +fn requirements_level() -> u64 { + REQUIREMENTS_LEVEL +} + +fn is_current_level(level: &u64) -> bool { + *level == REQUIREMENTS_LEVEL +} + +impl Default for ThemeFile { + fn default() -> Self { + Self { + id: String::new(), + name: String::new(), + base: Base::default(), + colors: BTreeMap::new(), + radii: BTreeMap::new(), + borders: BTreeMap::new(), + icon_palette: BTreeMap::new(), + tabs: BTreeMap::new(), + options: Vec::new(), + overlays: Vec::new(), + requires: REQUIREMENTS_LEVEL, + } + } +} + +/// Saved option values use the same JSON primitives as defaults and comparisons. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum OptionValue { + Bool(bool), + Choice(String), + Num(f32), +} + +/// Localized text belongs to the theme, with English as the fallback. +pub type OptionText = BTreeMap; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ChoiceValue { + pub id: String, + pub label: OptionText, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum OptionKind { + Bool { + default: bool, + }, + Choice { + default: String, + values: Vec, + }, + Range { + default: f32, + min: f32, + max: f32, + step: f32, + #[serde(default, skip_serializing_if = "Option::is_none")] + unit: Option, + }, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct OptionDef { + pub id: String, + #[serde(flatten)] + pub kind: OptionKind, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub label: OptionText, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub help: OptionText, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent: Option, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub advanced: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub section: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum Condition { + Eq { + opt: String, + eq: OptionValue, + }, + Range { + opt: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + gte: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + lte: Option, + }, +} + +impl Condition { + fn option(&self) -> &str { + match self { + Self::Eq { opt, .. } | Self::Range { opt, .. } => opt, + } + } +} + +/// Overlay tables have exactly the same keys and bounds as the theme's base tables. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct ThemeLayer { + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub colors: BTreeMap, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub radii: BTreeMap, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub borders: BTreeMap, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub icon_palette: BTreeMap, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Overlay { + pub when: Condition, + #[serde(flatten)] + pub values: ThemeLayer, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum OptionNoticeKind { + UnknownOption, + WrongType, + UnknownChoice, + NonFinite, + Clamped, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct OptionNotice { + pub id: String, + pub kind: OptionNoticeKind, } /// Maximum imported radius, in logical UI points; widgets clamp to their own bounds. @@ -104,6 +262,28 @@ fn radius_slot<'a>(t: &'a mut Tokens, key: &str) -> Option<&'a mut u8> { } impl ThemeFile { + /// Shared by import and preference restoration; invalid saved maps are dropped as a unit. + pub(crate) fn set_tabs(&mut self, source: &Value) -> Result<(), String> { + self.tabs = parse_tabs(source.get("tabs"))?; + Ok(()) + } + + /// A newer format level keeps the authored base tables but disables options and overlays. + /// Computed from `requires` so it also works after restoring saved preferences. + pub fn needs_newer(&self) -> bool { + self.requires > REQUIREMENTS_LEVEL + } + + /// Read option tables from a stored or directly supplied theme with the import parser. + /// Base tables are deliberately left alone; only completely valid declarations replace these. + pub(crate) fn set_option_tables(&mut self, source: &Value) -> Result<(), String> { + let options = if self.needs_newer() { Vec::new() } else { parse_options(source.get("options"))? }; + let overlays = if self.needs_newer() { Vec::new() } else { parse_overlays(source.get("overlays"), &options)? }; + self.options = options; + self.overlays = overlays; + Ok(()) + } + /// Validate direct control-channel preference updates as strictly as file imports. pub(crate) fn validate_radii(&self) -> Result<(), String> { for (key, value) in &self.radii { @@ -367,7 +547,8 @@ fn colors_for_key(key: &str, value: &Value, len: usize) -> Result, } /// A theme file's text, checked: the format tag, a name, a base and only known keys with valid -/// colours and bounded radii. Nothing is taken from a file with any error; the message names every bad key. +/// colours, bounded shapes, option definitions and overlay comparisons. Nothing is taken from a +/// supported-level file with any error; messages name the offending key and overlay position. pub fn parse(text: &str) -> Result { if text.len() > MAX_BYTES { return Err(format!("a theme file is at most {} KiB", MAX_BYTES / 1024)); @@ -390,6 +571,58 @@ pub fn from_value(v: &Value) -> Result { Some("light") => Base::Light, _ => return Err("`base` must be \"dark\" or \"light\"".into()), }; + let requires = match v.get("requires") { + None => REQUIREMENTS_LEVEL, + Some(value) => value.as_u64().ok_or("`requires` must be a nonnegative integer")?, + }; + let layer = parse_layer(v)?; + let tabs = parse_tabs(v.get("tabs"))?; + let options = if requires > REQUIREMENTS_LEVEL { Vec::new() } else { parse_options(v.get("options"))? }; + let overlays = if requires > REQUIREMENTS_LEVEL { Vec::new() } else { parse_overlays(v.get("overlays"), &options)? }; + Ok(ThemeFile { + id: slug(name), + name: name.to_string(), + base, + colors: layer.colors, + radii: layer.radii, + borders: layer.borders, + icon_palette: layer.icon_palette, + tabs, + options, + overlays, + requires, + }) +} + +/// Keep the accepted vocabulary at the same boundary as the existing accent mapping. +fn parse_tabs(v: Option<&Value>) -> Result>, String> { + let Some(v) = v else { return Ok(BTreeMap::new()) }; + let tabs = v.as_object().ok_or("`tabs` must be an object")?; + if tabs.len() > crate::theme::TAB_NAMES.len() { + return Err("`tabs` has too many entries".into()); + } + let mut parsed = BTreeMap::new(); + for (tab, surfaces) in tabs { + if !crate::theme::TAB_NAMES.contains(&tab.as_str()) { + return Err(format!("`tabs.{tab}` is not a ribbon tab")); + } + let surfaces = surfaces.as_object().ok_or_else(|| format!("`tabs.{tab}` must be an object"))?; + let mut values = BTreeMap::new(); + for (key, value) in surfaces { + if !matches!(key.as_str(), "panel" | "viewport_top" | "viewport_bottom") { + return Err(format!("`tabs.{tab}.{key}` is not a tab surface")); + } + if value.as_str().and_then(parse_color).is_none() { + return Err(format!("`tabs.{tab}.{key}` must be #rrggbb or #rrggbbaa")); + } + values.insert(key.clone(), value.clone()); + } + parsed.insert(tab.clone(), values); + } + Ok(parsed) +} + +fn parse_layer(v: &Value) -> Result { let mut src = match v.get("colors") { None => serde_json::Map::new(), Some(Value::Object(o)) => o.clone(), @@ -453,7 +686,228 @@ pub fn from_value(v: &Value) -> Result { Some(_) => return Err("`icon_palette` must be an object of SVG roles and ramps".into()), }; parse_icon_palette(&icon_palette)?; - Ok(ThemeFile { id: slug(name), name: name.to_string(), base, colors: src.into_iter().collect(), radii, borders, icon_palette }) + Ok(ThemeLayer { colors: src.into_iter().collect(), radii, borders, icon_palette }) +} + +fn valid_option_id(id: &str) -> bool { + (1..=32).contains(&id.len()) + && id.bytes().next().is_some_and(|b| b.is_ascii_lowercase()) + && id.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_') +} + +fn option_number(v: &Value, key: &str) -> Result { + v.get(key) + .and_then(Value::as_f64) + .map(|n| n as f32) + .filter(|n| n.is_finite()) + .ok_or_else(|| format!("`{key}` must be a finite number representable as f32")) +} + +fn option_text(v: Option<&Value>, key: &str, max: usize) -> Result { + let Some(v) = v else { return Ok(BTreeMap::new()) }; + let obj = v.as_object().ok_or_else(|| format!("`{key}` must be a map of language codes to strings"))?; + if obj.len() > 64 { + return Err(format!("`{key}` has more than 64 languages")); + } + obj.iter() + .map(|(language, value)| { + if language.is_empty() || language.len() > 32 || !language.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') { + return Err(format!("`{key}` has an invalid language code")); + } + let text = value + .as_str() + .filter(|s| s.chars().count() <= max) + .ok_or_else(|| format!("`{key}.{language}` must be a string of at most {max} characters"))?; + Ok((language.clone(), text.into())) + }) + .collect() +} + +fn parse_option(v: &Value) -> Result { + let id = v.get("id").and_then(Value::as_str).filter(|id| valid_option_id(id)).ok_or("`id` must match [a-z][a-z0-9_]{0,31}")?; + let kind = v.get("type").and_then(Value::as_str).ok_or("`type` must be bool, choice or range")?; + let reserved = STANDARD_OPTIONS.contains(&id); + let kind = if id == "tab_colours" || (!reserved && kind == "bool") { + if kind != "bool" { + return Err(format!("`{id}.type` must be bool")); + } + OptionKind::Bool { default: v.get("default").and_then(Value::as_bool).ok_or("`default` must be bool")? } + } else if reserved || kind == "range" { + if kind != "range" { + return Err(format!("`{id}.type` must be range")); + } + let (floor, ceiling, standard_unit) = match id { + "opacity" => (40.0, 100.0, Some("%")), + "blur" => (0.0, 40.0, Some("px")), + "tab_fade_ms" => (0.0, 600.0, Some("ms")), + _ => (0.0, 0.0, None), + }; + let min = if reserved && v.get("min").is_none() { floor } else { option_number(v, "min")? }; + let max = if reserved && v.get("max").is_none() { ceiling } else { option_number(v, "max")? }; + if min > max { + return Err("`min` must not exceed `max`".into()); + } + if reserved && (min < floor || max > ceiling) { + return Err(format!("`min` / `max` for {id} must stay inside {floor} to {ceiling}")); + } + let step = if id == "tab_fade_ms" { + 20.0 + } else if reserved { + 1.0 + } else { + option_number(v, "step")? + }; + if step <= 0.0 { + return Err("`step` must be greater than zero".into()); + } + let default = if id == "opacity" && v.get("default").is_none() { 100.0 } else { option_number(v, "default")? }; + let default = if reserved { + default.clamp(min, max) + } else { + if default < min || default > max { + return Err("`default` must be inside `min` / `max`".into()); + } + default + }; + let unit = if reserved { + standard_unit.map(str::to_string) + } else { + v.get("unit") + .map(|v| v.as_str().filter(|s| s.chars().count() <= 4).map(str::to_string).ok_or("`unit` must be a string of at most 4 characters")) + .transpose()? + }; + OptionKind::Range { default, min, max, step, unit } + } else if kind == "choice" { + let values = v.get("values").and_then(Value::as_array).filter(|v| (2..=12).contains(&v.len())).ok_or("`values` must have 2 to 12 choices")?; + let mut ids = std::collections::BTreeSet::new(); + let mut choices = Vec::new(); + for value in values { + let id = value.get("id").and_then(Value::as_str).filter(|id| valid_option_id(id)).ok_or("`values.id` must match [a-z][a-z0-9_]{0,31}")?; + if !ids.insert(id) { + return Err(format!("`values.id` duplicates {id}")); + } + let label = option_text(Some(value.get("label").ok_or("`values.label` is required")?), "values.label", 40)?; + choices.push(ChoiceValue { id: id.into(), label }); + } + let default = v.get("default").and_then(Value::as_str).filter(|id| ids.contains(id)).ok_or("`default` must name an id in `values`")?; + OptionKind::Choice { default: default.into(), values: choices } + } else { + return Err("`type` must be bool, choice or range".into()); + }; + // Standard definitions are app-owned: even malformed presentation fields are ignored. + if reserved { + return Ok(OptionDef { + id: id.into(), + kind, + label: BTreeMap::new(), + help: BTreeMap::new(), + parent: (id == "tab_fade_ms").then(|| "tab_colours".into()), + advanced: false, + section: None, + }); + } + let parent = v + .get("parent") + .map(|v| v.as_str().filter(|id| valid_option_id(id)).map(str::to_string).ok_or("`parent` must be an option id")) + .transpose()?; + let advanced = v.get("advanced").map(|v| v.as_bool().ok_or("`advanced` must be bool")).transpose()?.unwrap_or(false); + let section = v + .get("section") + .map(|v| v.as_str().filter(|s| s.chars().count() <= 24).map(str::to_string).ok_or("`section` must be a string of at most 24 characters")) + .transpose()?; + Ok(OptionDef { + id: id.into(), + kind, + label: option_text(v.get("label"), "label", 40)?, + help: option_text(v.get("help"), "help", 160)?, + parent, + advanced, + section, + }) +} + +fn parse_options(v: Option<&Value>) -> Result, String> { + let Some(v) = v else { return Ok(Vec::new()) }; + let values = v.as_array().filter(|v| v.len() <= MAX_OPTIONS).ok_or("`options` must be an array of at most 16 entries")?; + let mut options = Vec::new(); + let mut ids = std::collections::BTreeSet::new(); + for (position, value) in values.iter().enumerate() { + let option = parse_option(value).map_err(|e| format!("`options[{}]`: {e}", position + 1))?; + if !ids.insert(option.id.clone()) { + return Err(format!("`options.id` duplicates {}", option.id)); + } + options.push(option); + } + let mut parents = std::collections::BTreeSet::new(); + for option in &options { + if let Some(parent) = &option.parent { + if !options.iter().any(|o| &o.id == parent && matches!(o.kind, OptionKind::Bool { .. })) { + return Err(format!("`options.{}.parent` must name a bool option", option.id)); + } + if !parents.insert(parent) { + return Err(format!("`options.parent` {parent} has more than one child")); + } + let mut ancestor = Some(parent.as_str()); + for _ in 0..=MAX_OPTIONS { + let Some(id) = ancestor else { break }; + if id == option.id { + return Err(format!("`options.{}.parent` forms a cycle", option.id)); + } + ancestor = options.iter().find(|o| o.id == id).and_then(|o| o.parent.as_deref()); + } + } + } + Ok(options) +} + +fn parse_condition(v: &Value, options: &[OptionDef]) -> Result { + let obj = v.as_object().ok_or("`when` must be an object")?; + let opt = v.get("opt").and_then(Value::as_str).ok_or("`when.opt` must be an option id")?; + let option = options.iter().find(|o| o.id == opt).ok_or_else(|| format!("`when.opt` names unknown option {opt}"))?; + if let Some(eq) = v.get("eq") { + if obj.len() != 2 { + return Err("`when.eq` cannot be combined with other comparisons".into()); + } + let eq = match &option.kind { + OptionKind::Bool { .. } => OptionValue::Bool(eq.as_bool().ok_or("`when.eq` must be bool")?), + OptionKind::Choice { values, .. } => OptionValue::Choice( + eq.as_str().filter(|id| values.iter().any(|v| v.id == *id)).ok_or("`when.eq` must name a choice in `values`")?.into(), + ), + OptionKind::Range { .. } => OptionValue::Num(option_number(v, "eq").map_err(|e| format!("`when`: {e}"))?), + }; + return Ok(Condition::Eq { opt: opt.into(), eq }); + } + if !matches!(option.kind, OptionKind::Range { .. }) { + return Err("`when.gte` / `when.lte` require a range option".into()); + } + if obj.keys().any(|k| !matches!(k.as_str(), "opt" | "gte" | "lte")) { + return Err("`when` has an unknown comparison".into()); + } + let bound = |key| v.get(key).map(|_| option_number(v, key).map_err(|e| format!("`when`: {e}"))).transpose(); + let (gte, lte) = (bound("gte")?, bound("lte")?); + if gte.is_none() && lte.is_none() { + return Err("`when` needs eq, gte or lte".into()); + } + if matches!((gte, lte), (Some(a), Some(b)) if a > b) { + return Err("`when.gte` must not exceed `when.lte`".into()); + } + Ok(Condition::Range { opt: opt.into(), gte, lte }) +} + +fn parse_overlays(v: Option<&Value>, options: &[OptionDef]) -> Result, String> { + let Some(v) = v else { return Ok(Vec::new()) }; + let values = v.as_array().filter(|v| v.len() <= MAX_OVERLAYS).ok_or("`overlays` must be an array of at most 32 entries")?; + values + .iter() + .enumerate() + .map(|(position, value)| { + let parse = || { + let when = parse_condition(value.get("when").ok_or("`when` is required")?, options)?; + Ok(Overlay { when, values: parse_layer(value)? }) + }; + parse().map_err(|e: String| format!("`overlays[{}]`: {e}", position + 1)) + }) + .collect() } /// An id from a name: lower-case letters, digits and dashes. @@ -470,9 +924,181 @@ fn slug(name: &str) -> String { if s.is_empty() { "theme".into() } else { s } } -/// The theme's tokens: its base with its colours and radii on top. A colour the saved preferences hold -/// that this version can't read (a hand edit) is skipped, never an error. +/// Definitions from hand-edited preferences get the same bounds and reserved-id normalization +/// as imports. An invalid definition list cannot drive an overlay. +fn usable_options(theme: &ThemeFile) -> Vec { + if theme.needs_newer() || theme.options.len() > MAX_OPTIONS { + return Vec::new(); + } + serde_json::to_value(&theme.options).ok().and_then(|v| parse_options(Some(&v)).ok()).unwrap_or_default() +} + +impl OptionDef { + pub fn default_value(&self) -> OptionValue { + match &self.kind { + OptionKind::Bool { default } => OptionValue::Bool(*default), + OptionKind::Choice { default, .. } => OptionValue::Choice(default.clone()), + OptionKind::Range { default, .. } => OptionValue::Num(*default), + } + } +} + +/// Values for every declared option. Missing values use defaults without a notice; invalid saved +/// values use defaults and report why. Unknown ids are omitted (there is no definition/default). +/// Range values are clamped, without rounding to `step`. Inactive children retain their values. +pub fn effective_values(theme: &ThemeFile, saved: &BTreeMap) -> (BTreeMap, Vec) { + let options = usable_options(theme); + let mut values: BTreeMap<_, _> = options.iter().map(|o| (o.id.clone(), o.default_value())).collect(); + let mut notices = Vec::new(); + for (id, saved) in saved { + let Some(option) = options.iter().find(|o| &o.id == id) else { + notices.push(OptionNotice { id: id.clone(), kind: OptionNoticeKind::UnknownOption }); + continue; + }; + let mut notice = None; + let value = match (&option.kind, saved) { + (OptionKind::Bool { .. }, OptionValue::Bool(_)) => saved.clone(), + (OptionKind::Choice { values, .. }, OptionValue::Choice(id)) if values.iter().any(|v| &v.id == id) => saved.clone(), + (OptionKind::Choice { .. }, OptionValue::Choice(_)) => { + notice = Some(OptionNoticeKind::UnknownChoice); + option.default_value() + } + (OptionKind::Range { min, max, .. }, OptionValue::Num(n)) if n.is_finite() => { + let clamped = n.clamp(*min, *max); + if clamped != *n { + notice = Some(OptionNoticeKind::Clamped); + } + OptionValue::Num(clamped) + } + (OptionKind::Range { .. }, OptionValue::Num(_)) => { + notice = Some(OptionNoticeKind::NonFinite); + option.default_value() + } + _ => { + notice = Some(OptionNoticeKind::WrongType); + option.default_value() + } + }; + values.insert(id.clone(), value); + if let Some(kind) = notice { + notices.push(OptionNotice { id: id.clone(), kind }); + } + } + (values, notices) +} + +/// Standard options in the app's fixed order, each followed by its declared child, then custom +/// options in file order. Each definition appears once. No undeclared standard option is added. +pub fn ordered_options(theme: &ThemeFile) -> Vec<&OptionDef> { + if theme.needs_newer() { + return Vec::new(); + } + let options = theme.options.get(..theme.options.len().min(MAX_OPTIONS)).unwrap_or_default(); + let mut ordered: Vec<&OptionDef> = Vec::new(); + for id in ["opacity", "blur", "tab_colours"] { + let mut next = options.iter().find(|o| o.id == id); + for _ in 0..MAX_OPTIONS { + let Some(option) = next else { break }; + if ordered.iter().any(|o| o.id == option.id) { + break; + } + ordered.push(option); + next = options.iter().find(|o| o.parent.as_deref() == Some(option.id.as_str())); + } + } + for option in options { + if !ordered.iter().any(|o| o.id == option.id) { + ordered.push(option); + } + } + ordered +} + +fn option_active(id: &str, options: &[OptionDef], values: &BTreeMap) -> bool { + let mut current = id; + for _ in 0..=MAX_OPTIONS { + let Some(option) = options.iter().find(|o| o.id == current) else { return false }; + let Some(parent) = option.parent.as_deref() else { return true }; + if values.get(parent) != Some(&OptionValue::Bool(true)) { + return false; + } + current = parent; + } + false +} + +fn condition_matches(condition: &Condition, values: &BTreeMap) -> bool { + match condition { + Condition::Eq { opt, eq } => values.get(opt) == Some(eq), + Condition::Range { opt, gte, lte } => matches!(values.get(opt), Some(OptionValue::Num(n)) + if n.is_finite() && gte.is_none_or(|a| *n >= a) && lte.is_none_or(|b| *n <= b)), + } +} + +/// Resolve at declared defaults. Files without options retain the original palette exactly. pub fn resolve(theme: &ThemeFile) -> Tokens { + resolve_with(theme, &BTreeMap::new()) +} + +/// Resolve base tables, then matching overlays in file order. Later tables replace earlier +/// entries by key. Saved values are normalized here too, so callers cannot bypass finite checks. +pub fn resolve_with(theme: &ThemeFile, saved: &BTreeMap) -> Tokens { + resolve_for_tab(theme, saved, None) +} + +/// Resolve a tab and its base/overlays in one pass. Providers run last so opacity also protects +/// tab-authored panels. Restored/direct Rust values are revalidated before they affect tokens. +pub(crate) fn resolve_for_tab(theme: &ThemeFile, saved: &BTreeMap, tab: Option<&str>) -> Tokens { + if theme.needs_newer() || (theme.options.is_empty() && theme.overlays.is_empty()) || theme.overlays.len() > MAX_OVERLAYS { + return resolve_base(theme); + } + let options = usable_options(theme); + let overlays = serde_json::to_value(&theme.overlays).ok().and_then(|v| parse_overlays(Some(&v), &options).ok()).unwrap_or_default(); + let (values, _) = effective_values(theme, saved); + let mut merged = theme.clone(); + for overlay in overlays { + if !option_active(overlay.when.option(), &options, &values) || !condition_matches(&overlay.when, &values) { + continue; + } + // Legacy axis lists are local to a layer: an overlay's triad must override axes in + // the base, while explicit axis keys in that same overlay still win over its triad. + for key in LEGACY_AXES { + if let Some(Value::Array(colors)) = overlay.values.colors.get(key) { + for (axis, color) in ["axis_x", "axis_y", "axis_z"].into_iter().zip(colors) { + merged.colors.insert(axis.into(), color.clone()); + } + } + } + merged.colors.extend(overlay.values.colors); + merged.radii.extend(overlay.values.radii); + merged.borders.extend(overlay.values.borders); + merged.icon_palette.extend(overlay.values.icon_palette); + } + let mut tokens = resolve_base(&merged); + let mut tab_panel = false; + if crate::theme_options::tab_settings(theme, &values).0 + && let Some(tab) = tab + && let Ok(tabs) = serde_json::to_value(&theme.tabs).map_err(|e| e.to_string()).and_then(|v| parse_tabs(Some(&v))) + && let Some(surfaces) = tabs.get(tab) + { + for (key, value) in surfaces { + if let Some(slot) = slot(&mut tokens, key) + && let Ok(colors) = colors_for_key(key, value, 1) + { + slot.copy_from_slice(&colors); + } + } + tab_panel = surfaces.contains_key("panel"); + } + crate::theme_options::apply(theme, &values, &mut tokens); + if tab_panel { + tokens.protect_tab_panel(); + } + tokens +} + +/// A colour the saved preferences hold that this version cannot read is skipped safely. +fn resolve_base(theme: &ThemeFile) -> Tokens { let mut t = if theme.base.is_dark() { Tokens::dark() } else { Tokens::light() }; for k in LEGACY_AXES { if let Some(Ok(c)) = theme.colors.get(k).map(|v| colors_of(v, 3)) @@ -619,6 +1245,36 @@ pub fn export(name: &str, base: Base, t: &Tokens) -> Value { json!({"format": FORMAT, "name": name, "base": base, "colors": colors, "radii": radii, "borders": borders, "icon_palette": icon_palette}) } +/// Export a theme definition, preserving its options and overlays rather than flattening them +/// to the current tokens. The existing [`export`] remains a complete token snapshot. +pub fn export_file(theme: &ThemeFile) -> Value { + let mut value = json!({"format": FORMAT, "name": theme.name, "base": theme.base, "colors": theme.colors}); + if let Some(obj) = value.as_object_mut() { + if !theme.radii.is_empty() { + obj.insert("radii".into(), json!(theme.radii)); + } + if !theme.borders.is_empty() { + obj.insert("borders".into(), json!(theme.borders)); + } + if !theme.icon_palette.is_empty() { + obj.insert("icon_palette".into(), json!(theme.icon_palette)); + } + if !theme.tabs.is_empty() { + obj.insert("tabs".into(), json!(theme.tabs)); + } + if !theme.options.is_empty() { + obj.insert("options".into(), json!(theme.options)); + } + if !theme.overlays.is_empty() { + obj.insert("overlays".into(), json!(theme.overlays)); + } + if !is_current_level(&theme.requires) { + obj.insert("requires".into(), json!(theme.requires)); + } + } + value +} + // Theme sources are collected at build time; picker order is validated below. include!(concat!(env!("OUT_DIR"), "/builtin_themes.rs")); @@ -629,6 +1285,12 @@ pub(crate) fn is_two_colour(id: &str) -> bool { matches!(id, "1-bit" | "1-bit-dark") } +#[cfg(test)] +pub(crate) fn tab_fixture(base: &str) -> ThemeFile { + let catalog: Value = serde_json::from_str(include_str!("../tests/fixtures/theme-tabs.json")).unwrap(); + from_value(&catalog[base]).unwrap() +} + /// The built-in themes, validated and ordered once; a test checks every shipped file. pub fn builtin() -> &'static [ThemeFile] { static THEMES: OnceLock> = OnceLock::new(); @@ -676,11 +1338,24 @@ pub fn find<'a>(id: &str, custom: &'a [ThemeFile]) -> Option<&'a ThemeFile> { builtin().iter().find(|t| t.id == id).or_else(|| custom.iter().find(|t| t.id == id)) } +/// Retired built-in cards now select a contrast value on their light/dark family. +/// Keep these ids reserved so importing a theme cannot take a saved built-in identity. +pub(crate) const GRUVBOX_ALIASES: [(&str, &str, &str); 4] = [ + ("gruvbox-dark-hard", "gruvbox-dark", "hard"), + ("gruvbox-dark-soft", "gruvbox-dark", "soft"), + ("gruvbox-light-hard", "gruvbox-light", "hard"), + ("gruvbox-light-soft", "gruvbox-light", "soft"), +]; + +pub(crate) fn legacy_gruvbox(id: &str) -> Option<(&'static str, &'static str)> { + GRUVBOX_ALIASES.iter().find(|(old, _, _)| *old == id).map(|(_, family, contrast)| (*family, *contrast)) +} + /// Add an imported theme to `custom`, replacing one with the same id (importing a file again /// updates it). Its id never takes a built-in one's or the modes' (dark, light, system). /// Returns the id it is kept under. pub fn add(custom: &mut Vec, mut theme: ThemeFile) -> Result { - if ["dark", "light", "system"].contains(&theme.id.as_str()) || builtin().iter().any(|t| t.id == theme.id) { + if ["dark", "light", "system"].contains(&theme.id.as_str()) || builtin().iter().any(|t| t.id == theme.id) || legacy_gruvbox(&theme.id).is_some() { theme.id = format!("custom-{}", theme.id); } let id = theme.id.clone(); @@ -823,6 +1498,537 @@ impl Follow { mod tests { use super::*; + #[test] + fn tabs_import_is_strict_and_roundtrips_authored_surfaces() { + for base in ["dark", "light"] { + let file = tab_fixture(base); + assert_eq!(from_value(&export_file(&file)).unwrap(), file); + let mut source = export_file(&file); + source["tabs"] = json!(crate::theme::TAB_NAMES.iter().map(|tab| (*tab, json!({"panel":"#18202aff"}))).collect::>()); + assert_eq!(from_value(&source).unwrap().tabs.len(), crate::theme::TAB_NAMES.len()); + for (tabs, offending) in [ + (json!(null), "tabs"), + (json!([]), "tabs"), + (json!({"sketch":{}}), "sketch"), + (json!({"MISSING":{}}), "MISSING"), + (json!({"SKETCH":null}), "SKETCH"), + (json!({"SKETCH":{"panel":"#bad"}}), "panel"), + (json!({"SKETCH":{"panel":["#ffffff"]}}), "panel"), + (json!({"SKETCH":{"panel":12}}), "panel"), + ] { + source["tabs"] = tabs; + assert!(from_value(&source).unwrap_err().contains(offending)); + } + // Refuse every known non-surface token, not only status colours. + for key in keys().into_iter().filter(|key| !matches!(*key, "panel" | "viewport_top" | "viewport_bottom")) { + source["tabs"] = json!({"SKETCH":{key:"#ffffff"}}); + assert!(from_value(&source).unwrap_err().contains(key), "{key}"); + } + source["tabs"] = json!({"SKETCH":{"viewport_top":"#12345678"}}); + let file = from_value(&source).unwrap(); + assert_eq!(export_file(&file)["tabs"], source["tabs"]); + } + } + + #[test] + fn tabs_resolve_only_surfaces_and_off_is_exactly_base() { + for base in ["dark", "light"] { + let file = tab_fixture(base); + let defaults = BTreeMap::new(); + let base = resolve_with(&file, &defaults); + for tab in crate::theme::TAB_NAMES { + let mut want = base; + if let Some(surfaces) = file.tabs.get(*tab) { + for (key, value) in surfaces { + slot(&mut want, key).unwrap().copy_from_slice(&colors_of(value, 1).unwrap()); + } + } + let effective = resolve_for_tab(&file, &defaults, Some(tab)); + assert_eq!(effective, want, "{tab}"); + assert_eq!(crate::theme::TabAccent::for_tab(&effective, tab), crate::theme::TabAccent::for_tab(&base, tab)); + let off = BTreeMap::from([("tab_colours".into(), OptionValue::Bool(false))]); + assert_eq!(resolve_for_tab(&file, &off, Some(tab)), resolve_with(&file, &off)); + } + let mut damaged = file.clone(); + damaged.tabs.get_mut("SKETCH").unwrap().insert("error".into(), json!("#ffffff")); + assert_eq!(resolve_for_tab(&damaged, &defaults, Some("SKETCH")), base); + let mut absent = file.clone(); + absent.tabs.clear(); + assert_eq!(resolve_for_tab(&absent, &defaults, Some("SKETCH")), base); + assert_eq!(export_file(&file)["options"][1]["default"], 240.0); + } + for file in builtin() { + assert!(file.tabs.is_empty(), "{}", file.id); + assert_eq!(resolve_for_tab(file, &BTreeMap::new(), Some("SKETCH")), resolve(file)); + } + } + + fn option_theme(options: Value, overlays: Value) -> Value { + json!({"format":FORMAT,"name":"Options","base":"dark","options":options,"overlays":overlays}) + } + + fn sample_options() -> Value { + json!([ + {"id":"enabled","type":"bool","default":true,"label":{"en":"Enable","da":"Aktivér"},"help":{"en":"Theme-owned help"}}, + {"id":"style","type":"choice","default":"warm","parent":"enabled","advanced":true,"section":"Appearance", + "values":[{"id":"warm","label":{"en":"Warm"}},{"id":"cool","label":{"en":"Cool"}}]}, + {"id":"amount","type":"range","default":5,"min":0,"max":10,"step":0.5,"unit":"pt"} + ]) + } + + #[test] + fn option_definitions_reject_bad_keys_and_hostile_numbers() { + let mut cases = vec![ + (json!([{"id":"Bad","type":"bool","default":true}]), "id"), + (json!([{"id":"a","type":"script","default":true}]), "type"), + (json!([{"id":"a","type":"bool","default":1}]), "default"), + (json!([{"id":"a","type":"bool","default":true},{"id":"a","type":"bool","default":false}]), "id"), + (json!([{"id":"opacity","type":"bool","default":true}]), "type"), + (json!([{"id":"tab_colours","type":"range","default":1}]), "type"), + (json!([{"id":"a","type":"choice","default":"missing","values":[{"id":"x","label":{}},{"id":"y","label":{}}]}]), "default"), + (json!([{"id":"a","type":"choice","default":"x","values":[{"id":"x","label":{}},{"id":"x","label":{}}]}]), "values.id"), + (json!([{"id":"a","type":"choice","default":"x","values":[{"id":"x","label":{}}]}]), "values"), + (json!([{"id":"a","type":"range","default":1,"min":2,"max":1,"step":1}]), "min"), + (json!([{"id":"a","type":"range","default":3,"min":0,"max":2,"step":1}]), "default"), + (json!([{"id":"a","type":"range","default":1,"min":0,"max":2,"step":0}]), "step"), + (json!([{"id":"a","type":"range","default":1,"min":0,"max":2,"step":-1}]), "step"), + (json!([{"id":"a","type":"range","default":1,"min":0,"max":2,"step":1,"unit":"points"}]), "unit"), + (json!([{"id":"a","type":"bool","default":true,"label":{"en":"x".repeat(41)}}]), "label"), + (json!([{"id":"a","type":"bool","default":true,"help":{"en":"x".repeat(161)}}]), "help"), + (json!([{"id":"a","type":"bool","default":true,"section":"x".repeat(25)}]), "section"), + (json!([{"id":"a".repeat(33),"type":"bool","default":true}]), "id"), + (json!([{"id":"a","type":"bool","default":true,"parent":"missing"}]), "parent"), + (json!([{"id":"a","type":"bool","default":true,"advanced":"yes"}]), "advanced"), + (json!([{"id":"opacity","type":"range","default":80,"min":39}]), "min"), + (json!([{"id":"blur","type":"range","default":10,"max":41}]), "max"), + ]; + let mut too_many = Vec::new(); + for i in 0..17 { + too_many.push(json!({"id":format!("o{i}"),"type":"bool","default":true})); + } + cases.push((json!(too_many), "options")); + let mut too_many_choices = sample_options(); + too_many_choices[1]["values"] = json!((0..13).map(|i| json!({"id":format!("v{i}"),"label":{}})).collect::>()); + cases.push((too_many_choices, "values")); + for key in ["min", "max", "step", "default"] { + // JSON cannot encode NaN/infinity; null, strings and finite f64 values overflowing + // f32 must still name the precise field. Actual non-finite Rust values are below. + for value in [Value::Null, json!("NaN"), json!("Infinity"), json!(1e100), json!(-1e100)] { + let mut options = sample_options(); + options[2][key] = value; + cases.push((options, key)); + } + } + for (options, key) in cases { + let error = from_value(&option_theme(options, json!([]))).unwrap_err(); + assert!(error.contains(key), "expected {key}: {error}"); + } + for number in ["NaN", "Infinity", "-Infinity", "1e400"] { + let text = format!( + r#"{{"format":"{FORMAT}","name":"Bad","base":"dark","options":[{{"id":"x","type":"range","min":0,"max":10,"step":1,"default":{number}}}]}}"# + ); + assert!(parse(&text).is_err(), "{number} is not a JSON finite number"); + } + } + + #[test] + fn option_parents_must_be_unique_bool_parents_without_cycles() { + let mut options = sample_options(); + options[1]["parent"] = json!("amount"); + assert!(from_value(&option_theme(options, json!([]))).unwrap_err().contains("parent")); + let mut options = sample_options(); + options[2]["parent"] = json!("enabled"); + assert!(from_value(&option_theme(options, json!([]))).unwrap_err().contains("parent")); + for options in [ + json!([{"id":"a","type":"bool","default":true,"parent":"a"}]), + json!([{"id":"a","type":"bool","default":true,"parent":"b"},{"id":"b","type":"bool","default":true,"parent":"a"}]), + json!([{"id":"tab_fade_ms","type":"range","default":100}]), + ] { + assert!(from_value(&option_theme(options, json!([]))).unwrap_err().contains("parent")); + } + } + + #[test] + fn option_and_overlay_limits_accept_their_boundaries_and_count_unicode_characters() { + let mut options = sample_options().as_array().unwrap().clone(); + options[0]["label"] = json!({"en":"é".repeat(40)}); + options[0]["help"] = json!({"en":"é".repeat(160)}); + options[0]["section"] = json!("é".repeat(24)); + options[1]["values"] = json!((0..12).map(|i| json!({"id":format!("v{i}"),"label":{"en":"é".repeat(40)}})).collect::>()); + options[1]["default"] = json!("v11"); + options[2]["unit"] = json!("é".repeat(4)); + for i in options.len()..MAX_OPTIONS { + options.push(json!({"id":format!("o{i}"),"type":"bool","default":false})); + } + let overlays = (0..MAX_OVERLAYS).map(|i| json!({"when":{"opt":"enabled","eq":true},"radii":{"radius_sm":i}})).collect::>(); + let theme = from_value(&option_theme(json!(options), json!(overlays))).unwrap(); + assert_eq!((theme.options.len(), theme.overlays.len()), (MAX_OPTIONS, MAX_OVERLAYS)); + assert_eq!(resolve(&theme).radius_sm, 31); + assert_eq!(from_value(&export_file(&theme)).unwrap(), theme); + } + + #[test] + fn reserved_options_are_app_owned_and_ordered_with_their_children() { + let file = from_value(&option_theme( + json!([ + {"id":"custom_first","type":"bool","default":false}, + {"id":"tab_fade_ms","type":"range","default":9999,"min":100,"max":500,"unit":"bad","parent":"wrong","step":null}, + {"id":"tab_colours","type":"bool","default":true,"advanced":"ignored","label":null}, + {"id":"blur","type":"range","default":-5,"help":false,"section":42}, + {"id":"custom_child","type":"choice","default":"a","parent":"tab_fade_ms", + "values":[{"id":"a","label":{}},{"id":"b","label":{}}]}, + {"id":"opacity","type":"range","default":200,"min":60,"max":90,"step":0,"unit":false} + ]), + json!([]), + )) + .unwrap_err(); + assert!(file.contains("parent"), "a reserved range cannot be a bool parent"); + let file = from_value(&option_theme( + json!([ + {"id":"custom_first","type":"bool","default":false}, + {"id":"tab_fade_ms","type":"range","default":9999,"min":100,"max":500,"unit":"bad","parent":"wrong","step":null}, + {"id":"tab_colours","type":"bool","default":true,"advanced":"ignored","label":null}, + {"id":"blur","type":"range","default":-5,"help":false,"section":42}, + {"id":"custom_child","type":"bool","default":true,"parent":"custom_first"}, + {"id":"opacity","type":"range","default":200,"min":60,"max":90,"step":0,"unit":false} + ]), + json!([]), + )) + .unwrap(); + let (values, notices) = effective_values(&file, &BTreeMap::new()); + assert!(notices.is_empty()); + assert_eq!(values["opacity"], OptionValue::Num(90.0)); + assert_eq!(values["blur"], OptionValue::Num(0.0)); + assert_eq!(values["tab_fade_ms"], OptionValue::Num(500.0)); + let fade = file.options.iter().find(|o| o.id == "tab_fade_ms").unwrap(); + assert_eq!(fade.parent.as_deref(), Some("tab_colours")); + assert!(matches!(&fade.kind, OptionKind::Range { min:100.0, max:500.0, step:20.0, unit:Some(unit), .. } if unit == "ms")); + assert!( + file.options + .iter() + .filter(|o| STANDARD_OPTIONS.contains(&o.id.as_str())) + .all(|o| o.label.is_empty() && o.help.is_empty() && !o.advanced && o.section.is_none()) + ); + assert_eq!( + ordered_options(&file).iter().map(|o| o.id.as_str()).collect::>(), + ["opacity", "blur", "tab_colours", "tab_fade_ms", "custom_first", "custom_child"] + ); + let child = from_value(&option_theme( + json!([ + {"id":"child","type":"bool","default":false,"parent":"tab_colours"}, + {"id":"other","type":"bool","default":false}, + {"id":"tab_colours","type":"bool","default":true} + ]), + json!([]), + )) + .unwrap(); + assert_eq!(ordered_options(&child).iter().map(|o| o.id.as_str()).collect::>(), ["tab_colours", "child", "other"]); + } + + #[test] + fn effective_option_values_fall_back_clamp_and_report_each_saved_problem() { + let theme = from_value(&option_theme(sample_options(), json!([]))).unwrap(); + let defaults = effective_values(&theme, &BTreeMap::new()); + assert!(defaults.1.is_empty()); + for (saved, expected, kind) in [ + (OptionValue::Choice("typo".into()), OptionValue::Choice("warm".into()), OptionNoticeKind::UnknownChoice), + (OptionValue::Bool(false), OptionValue::Choice("warm".into()), OptionNoticeKind::WrongType), + ] { + let (values, notices) = effective_values(&theme, &BTreeMap::from([("style".into(), saved)])); + assert_eq!(values["style"], expected); + assert_eq!(notices, [OptionNotice { id: "style".into(), kind }]); + } + for (n, expected, kind) in [ + (-1.0, 0.0, OptionNoticeKind::Clamped), + (20.0, 10.0, OptionNoticeKind::Clamped), + (f32::NAN, 5.0, OptionNoticeKind::NonFinite), + (f32::INFINITY, 5.0, OptionNoticeKind::NonFinite), + (f32::NEG_INFINITY, 5.0, OptionNoticeKind::NonFinite), + ] { + let (values, notices) = effective_values(&theme, &BTreeMap::from([("amount".into(), OptionValue::Num(n))])); + assert_eq!(values["amount"], OptionValue::Num(expected)); + assert_eq!(notices, [OptionNotice { id: "amount".into(), kind }]); + } + let (values, notices) = effective_values( + &theme, + &BTreeMap::from([ + ("enabled".into(), OptionValue::Choice("bad".into())), + ("style".into(), OptionValue::Choice("bad".into())), + ("amount".into(), OptionValue::Num(99.0)), + ("removed".into(), OptionValue::Bool(true)), + ]), + ); + assert_eq!(notices.len(), 4); + assert!(!values.contains_key("removed")); + assert_eq!(values["enabled"], OptionValue::Bool(true)); + let saved = BTreeMap::from([ + ("enabled".into(), OptionValue::Bool(false)), + ("style".into(), OptionValue::Choice("cool".into())), + ("amount".into(), OptionValue::Num(4.25)), + ]); + assert_eq!(effective_values(&theme, &saved), (saved, Vec::new()), "step is a UI increment, not quantization"); + } + + #[test] + fn overlays_reject_unknown_options_types_comparisons_and_invalid_tables() { + let mut cases = vec![ + (json!({"opt":"missing","eq":true}), json!({}), "opt"), + (json!({"opt":"enabled","eq":1}), json!({}), "eq"), + (json!({"opt":"style","eq":"missing"}), json!({}), "eq"), + (json!({"opt":"amount","eq":false}), json!({}), "eq"), + (json!({"opt":"enabled","gte":0}), json!({}), "gte"), + (json!({"opt":"style","lte":10}), json!({}), "lte"), + (json!({"opt":"amount","gte":9,"lte":2}), json!({}), "gte"), + (json!({"opt":"amount"}), json!({}), "when"), + (json!({"opt":"amount","eq":2,"gte":0}), json!({}), "eq"), + (json!({"opt":"amount","gt":2}), json!({}), "when"), + (json!({"opt":"enabled","eq":true}), json!({"colors":{"panle":"#000000"}}), "panle"), + (json!({"opt":"enabled","eq":true}), json!({"radii":{"radius_sm":33}}), "radii.radius_sm"), + (json!({"opt":"enabled","eq":true}), json!({"borders":{"primary":{"width":5,"color":"#ffffff"}}}), "borders.primary.width"), + (json!({"opt":"enabled","eq":true}), json!({"icon_palette":{"ink":"#ffffff80"}}), "icon_palette.ink"), + ]; + for key in ["eq", "gte", "lte"] { + cases.push((json!({"opt":"amount",key:1e100}), json!({}), key)); + } + for (when, mut overlay, key) in cases { + overlay["when"] = when; + let error = from_value(&option_theme( + sample_options(), + json!([ + {"when":{"opt":"enabled","eq":false}}, overlay + ]), + )) + .unwrap_err(); + assert!(error.contains("overlays[2]") && error.contains(key), "{key}: {error}"); + } + let overlays = vec![json!({"when":{"opt":"enabled","eq":true}}); 33]; + assert!(from_value(&option_theme(sample_options(), json!(overlays))).unwrap_err().contains("overlays")); + } + + #[test] + fn matching_overlays_apply_in_file_order_and_preserve_other_table_entries() { + let mut source = option_theme( + sample_options(), + json!([ + {"when":{"opt":"enabled","eq":true},"colors":{"panel":"#123456","axis_x":"#111111"},"radii":{"radius_sm":1}, + "borders":{"primary":{"width":2,"color":"#abcdef"}},"icon_palette":{"ink":"#112233"}}, + {"when":{"opt":"amount","gte":5,"lte":5},"colors":{"panel":"#abcdef","triad":["#222222","#333333","#444444"]}, + "radii":{"radius_md":2},"icon_palette":{"line":"#445566"}}, + {"when":{"opt":"amount","eq":6},"colors":{"panel":"#000000"}}, + {"when":{"opt":"style","eq":"cool"},"colors":{"panel":"#ffffff"}} + ]), + ); + source["colors"] = json!({"text":"#fedcba","axis_x":"#000000"}); + source["radii"] = json!({"radius_lg":3}); + source["borders"] = json!({"control":{"width":1,"color":"#123456"}}); + source["icon_palette"] = json!({"solid":["#ffffff","#777777","#000000"]}); + let theme = from_value(&source).unwrap(); + let resolved = resolve(&theme); + assert_eq!(resolved.panel, parse_color("#abcdef").unwrap()); + assert_eq!(resolved.text, parse_color("#fedcba").unwrap()); + assert_eq!(resolved.axes(), [parse_color("#222222").unwrap(), parse_color("#333333").unwrap(), parse_color("#444444").unwrap()]); + assert_eq!((resolved.radius_sm, resolved.radius_md, resolved.radius_lg), (1, 2, 3)); + assert_eq!(resolved.borders.primary.unwrap().width, 2.0); + assert_eq!(resolved.borders.control.unwrap().width, 1.0); + let palette = export_icon_palette(resolved.icon_palette); + assert_eq!(palette["ink"], json!("#112233")); + assert_eq!(palette["line"], json!("#445566")); + assert_eq!(palette["solid"], source["icon_palette"]["solid"]); + assert_eq!(resolve_with(&theme, &BTreeMap::from([("amount".into(), OptionValue::Num(6.0))])).panel, Color32::BLACK); + let no_matches = BTreeMap::from([("enabled".into(), OptionValue::Bool(false)), ("amount".into(), OptionValue::Num(1.0))]); + assert_eq!(resolve_with(&theme, &no_matches), resolve_base(&theme)); + assert_eq!(resolve_with(&theme, &effective_values(&theme, &BTreeMap::new()).0), resolved); + } + + #[test] + fn inactive_children_never_match_overlays_and_values_resume_when_parent_is_on() { + let theme = from_value(&option_theme( + sample_options(), + json!([ + {"when":{"opt":"style","eq":"cool"},"colors":{"panel":"#123456"}} + ]), + )) + .unwrap(); + let mut saved = BTreeMap::from([("enabled".into(), OptionValue::Bool(false)), ("style".into(), OptionValue::Choice("cool".into()))]); + let effective = effective_values(&theme, &saved).0; + assert_eq!(effective["style"], OptionValue::Choice("cool".into())); + assert_eq!(resolve_with(&theme, &effective), resolve_base(&theme)); + saved.insert("enabled".into(), OptionValue::Bool(true)); + assert_eq!(resolve_with(&theme, &saved).panel, parse_color("#123456").unwrap()); + } + + #[test] + fn options_and_overlays_roundtrip_without_changing_old_export_shape() { + let theme = from_value(&option_theme( + sample_options(), + json!([ + {"when":{"opt":"amount","gte":4},"colors":{"panel":"#123456"}}, + {"when":{"opt":"style","eq":"warm"},"radii":{"radius_sm":0}} + ]), + )) + .unwrap(); + let exported = export_file(&theme); + assert_eq!(from_value(&exported).unwrap(), theme); + assert_eq!(parse(&exported.to_string()).unwrap(), theme); + assert_eq!(serde_json::from_value::(serde_json::to_value(&theme).unwrap()).unwrap(), theme); + let old = from_value(&json!({"format":FORMAT,"name":"Old","base":"light"})).unwrap(); + assert_eq!(from_value(&export_file(&old)).unwrap(), old); + for value in [export_file(&old), serde_json::to_value(&old).unwrap(), export("Snapshot", theme.base, &resolve(&theme))] { + for key in ["options", "overlays", "requires"] { + assert!(value.get(key).is_none(), "{key}: {value}"); + } + } + let snapshot = export("Snapshot", theme.base, &resolve(&theme)); + assert_eq!( + snapshot.as_object().unwrap().keys().map(String::as_str).collect::>(), + ["base", "borders", "colors", "format", "icon_palette", "name", "radii"] + ); + assert_eq!(resolve(&from_value(&snapshot).unwrap()), resolve(&theme)); + } + + #[test] + fn newer_requirements_keep_base_tables_without_reading_future_option_syntax() { + let source = json!({"format":FORMAT,"name":"Future","base":"light","requires":2,"colors":{"panel":"#123456"}, + "radii":{"radius_sm":0},"options":{"future":"syntax"},"overlays":false}); + let theme = from_value(&source).unwrap(); + assert!(theme.needs_newer()); + assert!(theme.options.is_empty() && theme.overlays.is_empty() && ordered_options(&theme).is_empty()); + assert_eq!(resolve(&theme).panel, parse_color("#123456").unwrap()); + assert_eq!(resolve(&theme).radius_sm, 0); + assert_eq!(from_value(&export_file(&theme)).unwrap(), theme); + let restored: ThemeFile = serde_json::from_value(serde_json::to_value(&theme).unwrap()).unwrap(); + assert!(restored.needs_newer()); + assert_eq!(resolve(&restored), resolve_base(&theme)); + for invalid in [json!(-1), json!(1.5), json!("2"), Value::Null] { + let mut source = source.clone(); + source["requires"] = invalid; + assert!(from_value(&source).unwrap_err().contains("requires")); + } + } + + #[test] + fn theme_options_opacity_only_changes_floating_fill_roles_and_full_is_exact() { + for base in ["light", "dark"] { + let mut source = json!({"format":FORMAT,"name":"Opacity","base":base,"options":[{"id":"opacity","type":"range"}]}); + let file = from_value(&source).unwrap(); + let original = resolve_base(&file); + assert_eq!(resolve(&file), original, "omitted default is 100"); + assert_eq!(resolve_with(&file, &BTreeMap::from([("opacity".into(), OptionValue::Num(100.0))])), original); + let faded = resolve_with(&file, &BTreeMap::from([("opacity".into(), OptionValue::Num(60.0))])); + let mut only_fills = original; + only_fills.panel = faded.panel; + only_fills.panel_header = faded.panel_header; + only_fills.dialog_bg = faded.dialog_bg; + only_fills.dialog_body = faded.dialog_body; + only_fills.dialog_footer = faded.dialog_footer; + only_fills.overlay = faded.overlay; + only_fills.nav_tip = faded.nav_tip; + assert_eq!(faded, only_fills, "every other token (including text/icons/borders) stays exact"); + assert!(faded.panel.a() < original.panel.a(), "the effect must actually change a panel"); + source["options"] = json!([]); + let without = from_value(&source).unwrap(); + assert_eq!(resolve_with(&without, &BTreeMap::from([("opacity".into(), OptionValue::Num(40.0))])), resolve_base(&without)); + } + } + + #[test] + fn theme_options_opacity_contrast_protects_text_at_40_over_viewport_extremes() { + use crate::theme::{composite, contrast_ratio}; + for base in ["light", "dark"] { + let file = from_value(&json!({"format":FORMAT,"name":"Contrast","base":base, + "options":[{"id":"opacity","type":"range","default":40}]})) + .unwrap(); + let full = resolve_base(&file); + let faded = resolve(&file); + for (role, fill) in [ + ("panel", faded.panel), + ("panel_header", faded.panel_header), + ("dialog_bg", faded.dialog_bg), + ("dialog_body", faded.dialog_body), + ("dialog_footer", faded.dialog_footer), + ("overlay", faded.overlay), + ] { + for background in [Color32::BLACK, Color32::WHITE, full.viewport_top, full.viewport_bottom] { + let ground = composite(fill, background); + for ink in [faded.text, faded.form_ink, faded.dialog_label] { + assert!(contrast_ratio(composite(ink, ground), ground) >= 4.5, "{base} {role} {ink:?} on {background:?}: {fill:?}"); + } + } + } + for background in [Color32::BLACK, Color32::WHITE] { + let ground = composite(faded.nav_tip, background); + assert!(contrast_ratio(composite(faded.nav_tip_ink, ground), ground) >= 4.5); + } + assert!(faded.panel.a() > (f32::from(full.panel.a()) * 0.4).round() as u8, "raise the floor when requested alpha is unreadable"); + assert!(faded.panel.a() < 255, "do not make all options effectively opaque"); + assert_eq!(resolve_with(&file, &BTreeMap::from([("opacity".into(), OptionValue::Num(-10.0))])), faded, "theme API still clamps"); + assert_eq!( + resolve_with(&file, &BTreeMap::from([("opacity".into(), OptionValue::Num(f32::NAN))])), + faded, + "non-finite values use defaults" + ); + } + } + + #[test] + fn theme_options_opacity_is_applied_after_overlays_and_uses_the_theme_default() { + let file = from_value(&json!({"format":FORMAT,"name":"Overlay opacity","base":"dark", + "options":[{"id":"opacity","type":"range","default":60},{"id":"warm","type":"bool","default":true}], + "overlays":[{"when":{"opt":"warm","eq":true},"colors":{"panel":"#111111"}}]})) + .unwrap(); + let faded = resolve(&file); + let full = resolve_with(&file, &BTreeMap::from([("opacity".into(), OptionValue::Num(100.0))])); + assert_eq!(full.panel, Color32::from_rgb(17, 17, 17)); + assert!(faded.panel.a() < full.panel.a()); + assert_eq!(faded.text, full.text); + // Premultiplied Color32 storage can quantize unmultiplied channels by one byte. + assert!(faded.panel.to_srgba_unmultiplied()[..3].iter().all(|c| c.abs_diff(17) <= 1)); + } + + #[test] + fn all_shipped_themes_keep_the_original_resolver_output_and_serialization() { + assert_eq!(builtin().len(), BUILTIN.len()); + for theme in builtin() { + let gruvbox = matches!(theme.id.as_str(), "gruvbox-dark" | "gruvbox-light"); + assert!(!theme.needs_newer()); + assert_eq!(!theme.options.is_empty(), gruvbox); + assert_eq!(!theme.overlays.is_empty(), gruvbox); + assert_eq!(resolve(theme), resolve_base(theme), "{}", theme.id); + assert_eq!(resolve_with(theme, &BTreeMap::from([("opacity".into(), OptionValue::Num(40.0))])), resolve_base(theme), "{}", theme.id); + let stored = serde_json::to_value(theme).unwrap(); + assert!(stored.get("requires").is_none(), "{}", theme.id); + for key in ["options", "overlays"] { + assert_eq!(stored.get(key).is_some(), gruvbox, "{}: {key}", theme.id); + } + } + } + + #[test] + fn malformed_saved_definitions_and_comparisons_resolve_without_panics() { + let mut theme = from_value(&option_theme( + sample_options(), + json!([ + {"when":{"opt":"amount","gte":0},"colors":{"panel":"#ffffff"}} + ]), + )) + .unwrap(); + for invalid in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + let original = theme.clone(); + theme.options[2].kind = OptionKind::Range { default: invalid, min: 0.0, max: 10.0, step: 1.0, unit: None }; + assert_eq!(resolve(&theme), resolve_base(&theme)); + theme = original.clone(); + theme.options[2].kind = OptionKind::Range { default: 5.0, min: invalid, max: 10.0, step: 1.0, unit: None }; + assert_eq!(resolve(&theme), resolve_base(&theme)); + theme = original.clone(); + theme.overlays[0].when = Condition::Range { opt: "amount".into(), gte: Some(invalid), lte: None }; + assert_eq!(resolve(&theme), resolve_base(&theme)); + theme = original; + } + theme.requires = 2; + assert_eq!(resolve(&theme), resolve_base(&theme)); + assert!(effective_values(&theme, &BTreeMap::new()).0.is_empty()); + } + #[test] fn navigation_board_colours_are_themeable_and_roundtrip_on_both_bases() { for (base, tokens, colours) in [ @@ -902,7 +2108,7 @@ mod tests { assert_eq!(resolve(&file).borders, crate::theme::Borders::LEGACY); assert!(parse_borders(&BTreeMap::from([("primary".into(), json!({"width":null,"color":"#ffffff"}))])).is_err()); assert_eq!(resolve(find("blueprint", &[]).unwrap()).borders.primary.unwrap().color, Color32::WHITE); - for id in ["gruvbox-dark", "gruvbox-light", "gruvbox-dark-soft", "gruvbox-light-soft"] { + for id in ["gruvbox-dark", "gruvbox-light"] { assert_eq!(resolve(find(id, &[]).unwrap()).borders, crate::theme::Borders::LEGACY); } } @@ -966,17 +2172,22 @@ mod tests { use crate::theme::{TabAccent, contrast_ratio}; assert_eq!(builtin().len(), BUILTIN.len(), "all shipped themes must be valid"); for file in builtin() { - let t = resolve(file); - assert!(t.icon_palette.is_some(), "{} has no icon palette", file.id); - // A two-colour theme (1-bit, 1-bit Dark) can't give each workspace its own fill; it - // still has to keep every tab readable, below. - let two_colour = is_two_colour(&file.id); - let mut fills = std::collections::HashSet::new(); - for tab in ["MODEL", "SURFACE", "MESH", "FORM", "SHEET METAL", "RENDER", "SKETCH"] { - let accent = TabAccent::for_tab(&t, tab); - assert!(fills.insert(accent.color) || two_colour, "{} repeats {tab}", file.id); - assert!(contrast_ratio(accent.ink, accent.soft) >= 4.5, "{} {tab} soft text", file.id); - assert!(contrast_ratio(accent.on_fill(&t), accent.color) >= 4.5, "{} {tab} pill text", file.id); + let gruvbox = matches!(file.id.as_str(), "gruvbox-dark" | "gruvbox-light"); + let contrasts: &[&str] = if gruvbox { &["soft", "standard", "hard"] } else { &["standard"] }; + for contrast in contrasts { + let options = if gruvbox { BTreeMap::from([("contrast".into(), OptionValue::Choice((*contrast).into()))]) } else { BTreeMap::new() }; + let t = resolve_with(file, &options); + assert!(t.icon_palette.is_some(), "{} {contrast} has no icon palette", file.id); + // A two-colour theme (1-bit, 1-bit Dark) can't give each workspace its own fill; it + // still has to keep every tab readable, below. + let two_colour = is_two_colour(&file.id); + let mut fills = std::collections::HashSet::new(); + for tab in ["MODEL", "SURFACE", "MESH", "FORM", "SHEET METAL", "RENDER", "SKETCH"] { + let accent = TabAccent::for_tab(&t, tab); + assert!(fills.insert(accent.color) || two_colour, "{} {contrast} repeats {tab}", file.id); + assert!(contrast_ratio(accent.ink, accent.soft) >= 4.5, "{} {contrast} {tab} soft text", file.id); + assert!(contrast_ratio(accent.on_fill(&t), accent.color) >= 4.5, "{} {contrast} {tab} pill text", file.id); + } } } } @@ -1061,15 +2272,56 @@ mod tests { assert_eq!(t.file_image_tint, Color32::WHITE, "{id}"); } + #[test] + fn gruvbox_contrast_choices_keep_six_appearances() { + let families: Vec<_> = builtin().iter().filter(|f| f.id.starts_with("gruvbox-")).map(|f| f.id.as_str()).collect(); + assert_eq!(families, ["gruvbox-dark", "gruvbox-light"]); + for (id, panels, tops, bottoms, inks) in [ + ( + "gruvbox-dark", + ["#32302f", "#282828", "#1d2021"], + ["#373432", "#32302f", "#2c2c2c"], + ["#373432", "#32302f", "#2c2c2c"], + ["#1d2021", "#1d2021", "#141617"], + ), + ("gruvbox-light", ["#f2e5bc", "#fbf1c7", "#f9f5d7"], ["#f6edd0", "#fcf5d8", "#fbf8e3"], ["#ebdbb2"; 3], ["#3c3836"; 3]), + ] { + let file = find(id, &[]).unwrap(); + assert_eq!(file.options.len(), 1); + let option = &file.options[0]; + assert_eq!(option.id, "contrast"); + let OptionKind::Choice { default, values } = &option.kind else { panic!("contrast must be a choice") }; + assert_eq!(default, "standard"); + assert_eq!(values.iter().map(|v| v.id.as_str()).collect::>(), ["soft", "standard", "hard"]); + for (index, contrast) in ["soft", "standard", "hard"].into_iter().enumerate() { + let options = BTreeMap::from([("contrast".into(), OptionValue::Choice(contrast.into()))]); + let tokens = resolve_with(file, &options); + assert_eq!(tokens.panel, parse_color(panels[index]).unwrap(), "{id}: {contrast}"); + assert_eq!(tokens.viewport_top, parse_color(tops[index]).unwrap(), "{id}: {contrast}"); + assert_eq!(tokens.viewport_bottom, parse_color(bottoms[index]).unwrap(), "{id}: {contrast}"); + assert_eq!(export_icon_palette(tokens.icon_palette)["ink"], inks[index], "{id}: {contrast}"); + assert_eq!(tokens.borders, crate::theme::Borders::LEGACY); + assert_eq!(resolve_with(&from_value(&export_file(file)).unwrap(), &options), tokens); + } + assert_eq!(resolve(file), resolve_base(file), "the Standard default keeps the old family palette"); + } + } + + #[test] + fn gruvbox_retired_ids_stay_reserved_for_imported_names() { + let mut custom = Vec::new(); + for (old, _, _) in GRUVBOX_ALIASES { + let file = from_value(&json!({"format":FORMAT,"name":old,"base":"dark"})).unwrap(); + assert_eq!(add(&mut custom, file).unwrap(), format!("custom-{old}")); + assert!(find(old, &custom).is_none(), "retired ids are not duplicate theme cards"); + } + } + #[test] fn builtin_order_preserves_the_existing_picker_sequence() { let original: &[&str] = &[ "gruvbox-dark", - "gruvbox-dark-hard", - "gruvbox-dark-soft", "gruvbox-light", - "gruvbox-light-hard", - "gruvbox-light-soft", "solidcraft-95", "1-bit", "1-bit-dark", @@ -1402,9 +2654,11 @@ mod tests { } let defaults = radius_values(Tokens::dark()); assert_eq!(defaults, radius_values(Tokens::light())); - for suffix in ["", "-hard", "-soft"] { - assert_eq!(values(&format!("gruvbox-dark{suffix}")), defaults); - assert_eq!(values(&format!("gruvbox-light{suffix}")), defaults); + for id in ["gruvbox-dark", "gruvbox-light"] { + for contrast in ["soft", "standard", "hard"] { + let options = BTreeMap::from([("contrast".into(), OptionValue::Choice(contrast.into()))]); + assert_eq!(radius_values(resolve_with(find(id, &[]).unwrap(), &options)), defaults); + } } for file in builtin().iter().filter(|file| file.radii.is_empty()) { assert_eq!(radius_values(resolve(file)), defaults, "{}", file.id); diff --git a/crates/ui-egui/src/theme_options.rs b/crates/ui-egui/src/theme_options.rs new file mode 100644 index 0000000000..3081b73516 --- /dev/null +++ b/crates/ui-egui/src/theme_options.rs @@ -0,0 +1,121 @@ +//! Standard behaviours are registered here; file declarations alone do not offer a control. +use crate::{ + theme::Tokens, + theme_files::{OptionDef, OptionValue, ThemeFile}, +}; +use std::collections::BTreeMap; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Availability { + Available, + /// Message-catalog id, translated at the presentation boundary. + #[allow(dead_code)] // Hardware-dependent registrations arrive with blur and tab providers. + Unavailable(&'static str), +} + +pub(crate) struct Provider { + pub id: &'static str, + pub label: &'static str, + pub help: &'static str, + pub availability: fn() -> Availability, + pub apply: fn(&mut Tokens, &OptionValue), +} + +pub(crate) const PROVIDERS: &[Provider] = &[ + Provider { + id: "opacity", + label: "prefs.theme_option_opacity", + help: "prefs.theme_option_opacity_help", + availability: || Availability::Available, + apply: apply_opacity, + }, + Provider { + id: "tab_colours", + label: "prefs.theme_option_tab_colours", + help: "prefs.theme_option_tab_colours_help", + availability: || Availability::Available, + apply: apply_tab_setting, + }, + Provider { + id: "tab_fade_ms", + label: "prefs.theme_option_tab_fade_ms", + help: "prefs.theme_option_tab_fade_ms_help", + availability: || Availability::Available, + apply: apply_tab_setting, + }, +]; + +/// Tab providers affect resolution/animation rather than the base palette. Undeclared values +/// and inactive parents cannot enable them, even for a directly supplied theme. +pub(crate) fn tab_settings(file: &ThemeFile, values: &BTreeMap) -> (bool, f32) { + let offered = offered(file, PROVIDERS); + let enabled = offered.iter().any(|o| o.id == "tab_colours") && values.get("tab_colours") == Some(&OptionValue::Bool(true)); + let duration = if enabled && offered.iter().any(|o| o.id == "tab_fade_ms") { + match values.get("tab_fade_ms") { + Some(OptionValue::Num(n)) if n.is_finite() => n.clamp(0.0, 600.0), + _ => 0.0, + } + } else { + 0.0 + }; + (enabled, duration) +} + +// Consumed by tab_settings at the per-tab resolution boundary; never mutate base tokens. +fn apply_tab_setting(_: &mut Tokens, _: &OptionValue) {} + +pub(crate) fn standard(id: &str) -> bool { + matches!(id, "opacity" | "blur" | "tab_colours" | "tab_fade_ms") +} + +pub(crate) fn provider<'a>(id: &str, providers: &'a [Provider]) -> Option<&'a Provider> { + providers.iter().find(|p| p.id == id) +} + +/// A child of an absent provider is absent too, even if it has a custom id. +pub(crate) fn offered<'a>(file: &'a ThemeFile, providers: &[Provider]) -> Vec<&'a OptionDef> { + let ordered = crate::theme_files::ordered_options(file); + ordered + .iter() + .copied() + .filter(|option| { + let mut current = Some(*option); + for _ in 0..=crate::theme_files::MAX_OPTIONS { + let Some(def) = current else { return true }; + if standard(&def.id) && provider(&def.id, providers).is_none() { + return false; + } + current = def.parent.as_ref().and_then(|id| ordered.iter().find(|o| &o.id == id).copied()); + } + false + }) + .collect() +} + +pub(crate) fn apply(file: &ThemeFile, values: &BTreeMap, tokens: &mut Tokens) { + let offered = offered(file, PROVIDERS); + for def in &offered { + let mut parent = def.parent.as_deref(); + let mut active = true; + for _ in 0..crate::theme_files::MAX_OPTIONS { + let Some(id) = parent else { break }; + active &= values.get(id) == Some(&OptionValue::Bool(true)); + active &= provider(id, PROVIDERS).is_none_or(|p| (p.availability)() == Availability::Available); + parent = offered.iter().find(|o| o.id == id).and_then(|o| o.parent.as_deref()); + } + if let Some(provider) = provider(&def.id, PROVIDERS) + && active + && (provider.availability)() == Availability::Available + && let Some(value) = values.get(&def.id) + { + (provider.apply)(tokens, value); + } + } +} + +/// Adapt the option value; all colour math belongs to the theme boundary. +fn apply_opacity(t: &mut Tokens, value: &OptionValue) { + if let OptionValue::Num(percent) = value { + t.apply_panel_opacity(*percent); + } +} diff --git a/crates/ui-egui/src/timeline.rs b/crates/ui-egui/src/timeline.rs index d78b5840f1..29d1a7128b 100644 --- a/crates/ui-egui/src/timeline.rs +++ b/crates/ui-egui/src/timeline.rs @@ -384,7 +384,7 @@ pub(crate) fn clipboard_buttons(app: &mut SolveApp, ui: &mut egui::Ui, id: u64) /// Where each feature's result is in `results`, by feature id: the first result of an id, as /// `Model::result` finds it. -fn result_index(results: &[solidcraft_engine::doc::FeatureResult]) -> std::collections::HashMap { +pub(crate) fn result_index(results: &[solidcraft_engine::doc::FeatureResult]) -> std::collections::HashMap { results.iter().enumerate().rev().map(|(k, r)| (r.id, k)).collect() } @@ -500,6 +500,10 @@ fn select_range(app: &mut SolveApp, order: &[u64], id: u64, add: bool) { /// Give a History feature click to a compatible active dialog input in any layout. /// Plain clicks keep an existing pick; Ctrl/Command clicks toggle it off. pub(crate) fn pick_feature_into_dialog(app: &mut SolveApp, id: u64, modifiers: egui::Modifiers) -> bool { + // A pattern or Mirror says why it does not take a sketch (#10057). + if app.dialog.as_mut().is_some_and(|d| d.refuse_sketch(&app.session, id)) { + return true; + } let Some(d) = app.dialog.as_mut().filter(|d| d.active_input().is_some_and(|i| i.accept & crate::selection::FEATURES != 0)) else { return false; }; @@ -884,6 +888,19 @@ fn timeline_contents(app: &mut SolveApp, ui: &mut egui::Ui, floating: bool) { // Each feature's result, found once for the strip: a search per chip made a long // timeline cost the square of its length every frame. let results = result_index(&app.session.model.results); + // The features picked in the design or in the open dialog, collected once for the + // strip: the dialog built its item list again for every chip. + let feature_of = |selection: &solidcraft_engine::Sel| match selection { + solidcraft_engine::Sel::Feature { id } => Some(*id), + _ => None, + }; + let picked: std::collections::HashSet = app + .session + .selection + .iter() + .filter_map(feature_of) + .chain(app.dialog.as_ref().map(|dialog| dialog.items()).unwrap_or_default().iter().filter_map(feature_of)) + .collect(); for tile in folder_tiles(app, &order) { let i = match tile { FolderTile::Folder(id) => { @@ -950,8 +967,7 @@ fn timeline_contents(app: &mut SolveApp, ui: &mut egui::Ui, floating: bool) { let err = res.and_then(|r| r.error.clone()); let warn = res.and_then(|r| r.warning.clone()); let rolled = i >= marker; - let is_it = |s: &solidcraft_engine::Sel| matches!(s, solidcraft_engine::Sel::Feature { id: f } if f == id); - let sel = app.session.selection.iter().any(is_it) || app.dialog.as_ref().is_some_and(|d| d.items().iter().any(is_it)); + let sel = picked.contains(id); if resp.hovered() { hovered = Some(*id); } @@ -974,25 +990,30 @@ fn timeline_contents(app: &mut SolveApp, ui: &mut egui::Ui, floating: bool) { if floating && marker > 0 && i == marker - 1 { p.circle_filled(pos2(br.center().x, br.bottom() + t.space_3), t.space_2, accent.color); } - let mut tip = status_text(name, ty, err.as_deref(), warn.as_deref()); - // Greyed out says only "not shown": say why (#8100). - if rolled { - tip.push('\n'); - tip.push_str(crate::i18n::tr("timeline.feature_rolled_back")); - } - if let Some(state) = sketch_state { - tip.push('\n'); - tip.push_str(&state.text()); - } - if let Some(r) = res { - tip += &format!("\n{:.1} ms", r.ms); - } - tip.push('\n'); - tip.push_str(crate::i18n::tr("timeline.feature_hint")); + let ms = res.map(|r| r.ms); if resp.drag_started() { drag = Some(Drag::Feature(*id)); } - let resp = resp.on_hover_text(tip); + // The tip is written only while it shows (as `on_hover_text` would lay it out). + let resp = resp.on_hover_ui(|ui| { + let mut tip = status_text(name, ty, err.as_deref(), warn.as_deref()); + // Greyed out says only "not shown": say why (#8100). + if rolled { + tip.push('\n'); + tip.push_str(crate::i18n::tr("timeline.feature_rolled_back")); + } + if let Some(state) = sketch_state { + tip.push('\n'); + tip.push_str(&state.text()); + } + if let Some(ms) = ms { + tip += &format!("\n{ms:.1} ms"); + } + tip.push('\n'); + tip.push_str(crate::i18n::tr("timeline.feature_hint")); + ui.set_max_width(ui.spacing().tooltip_width); + ui.add(egui::Label::new(tip)); + }); if resp.clicked() && !pick_feature_into_dialog(app, *id, ui.input(|i| i.modifiers)) { let modifiers = ui.input(|i| i.modifiers); if modifiers.shift { diff --git a/crates/ui-egui/src/timeline_left.rs b/crates/ui-egui/src/timeline_left.rs index c4b906f09d..dbcbcdcee5 100644 --- a/crates/ui-egui/src/timeline_left.rs +++ b/crates/ui-egui/src/timeline_left.rs @@ -7,10 +7,14 @@ use serde_json::json; use crate::history_rows::{HistoryFeature, HistoryRows}; use crate::theme::Tokens; use crate::timeline::{ - TimelineKind, clipboard_buttons, delete_after_marker_button, delete_button, delete_features, icon_of, message_button, selected_feature_ids, + TimelineKind, clipboard_buttons, delete_after_marker_button, delete_button, delete_features, message_button, selected_feature_ids, }; use crate::{SolveApp, icons}; +mod plan; + +use plan::RowPlan; + const FOLDER_INDENT: f32 = 14.0; const DROP_LEVEL_ANCHOR: f32 = 12.0; const DROP_BAR_INSET: f32 = 4.0; @@ -893,6 +897,8 @@ fn group_is_open(app: &SolveApp, groups: &[solidcraft_engine::doc::TimelineGroup true } +/// The reference the cached [`RowPlan`] is tested against. +#[cfg(test)] fn group_ancestors_are_open(app: &SolveApp, groups: &[solidcraft_engine::doc::TimelineGroup], id: u64) -> bool { let mut current = groups.iter().find(|group| group.id == id).and_then(|group| group.parent); let mut steps = 0usize; @@ -909,6 +915,8 @@ fn group_ancestors_are_open(app: &SolveApp, groups: &[solidcraft_engine::doc::Ti true } +/// The reference the cached [`RowPlan`] is tested against. +#[cfg(test)] fn group_first_feature( groups: &[solidcraft_engine::doc::TimelineGroup], features: &[(u64, String, &'static str, bool, &'static str, TimelineKind)], @@ -920,6 +928,8 @@ fn group_first_feature( direct.into_iter().chain(children).min() } +/// The reference the cached [`RowPlan`] is tested against. +#[cfg(test)] fn group_last_feature( groups: &[solidcraft_engine::doc::TimelineGroup], features: &[(u64, String, &'static str, bool, &'static str, TimelineKind)], @@ -932,6 +942,7 @@ fn group_last_feature( } /// A folder with only joints shares its nearest feature-bearing ancestor's position. +#[cfg(test)] fn group_position( groups: &[solidcraft_engine::doc::TimelineGroup], features: &[(u64, String, &'static str, bool, &'static str, TimelineKind)], @@ -971,6 +982,8 @@ pub(crate) fn sketches_key(feature: u64) -> String { format!("timeline/feature/{feature}/sketches") } +/// The reference the cached [`RowPlan`] is tested against. +#[cfg(test)] fn visible_order( app: &SolveApp, features: &[(u64, String, &'static str, bool, &'static str, TimelineKind)], @@ -1066,7 +1079,9 @@ fn vertical_group_row( names: bool, ui: &mut egui::Ui, group: &solidcraft_engine::doc::TimelineGroup, - groups: &[solidcraft_engine::doc::TimelineGroup], + table: &RowPlan, + contents: &[u64], + joints: &[u64], order: &[TimelineItem], drag: &mut Option, depth: usize, @@ -1100,15 +1115,9 @@ fn vertical_group_row( let centre = pos2(rect.left() + 9.0 + offset, rect.center().y); crate::ui_kit::disclosure(ui.painter(), centre, open, t.text_dim); // A folder whose features are all suppressed looks like a suppressed feature. - let contents = folder_features(app, &[group.id]); - let joints: Vec<_> = groups - .iter() - .filter(|candidate| candidate.id == group.id || group_is_descendant(groups, candidate.id, group.id)) - .flat_map(|group| group.joints.iter()) - .collect(); let suppressed = (!contents.is_empty() || !joints.is_empty()) - && contents.iter().all(|id| app.session.doc.feature(*id).is_some_and(|f| f.suppressed)) - && joints.iter().all(|id| app.session.doc.assembly.joints.iter().any(|joint| joint.id == **id && joint.suppressed)); + && contents.iter().all(|id| table.suppressed(*id)) + && joints.iter().all(|id| app.session.doc.assembly.joints.iter().any(|joint| joint.id == *id && joint.suppressed)); let mut painter = ui.painter().clone(); if suppressed { painter.multiply_opacity(0.42); @@ -1119,7 +1128,7 @@ fn vertical_group_row( painter.line_segment([icon.left_bottom(), icon.right_top()], Stroke::new(1.5, t.text_dim)); } // Folded, it carries the worst mark of its features, as a feature chip marks its own (#8592). - let (status, tip) = if open { (None, None) } else { crate::timeline::worst_status(app, &contents) }; + let (status, tip) = if open { (None, None) } else { crate::timeline::worst_status(app, contents) }; if let Some(status) = status { let side = if names { t.space_12 } else { t.space_10 }; let at = @@ -1165,7 +1174,7 @@ fn vertical_group_row( ui.close(); } ui.separator(); - selection_actions(app, ui, groups, false); + selection_actions(app, ui, &table.groups, false); }); open } @@ -1208,12 +1217,14 @@ struct Rows<'a> { names: bool, /// Sketches: Under their feature, while the History shows names (#6489). under_feature: bool, - features: &'a [(u64, String, &'static str, bool, &'static str, TimelineKind)], - groups: &'a [solidcraft_engine::doc::TimelineGroup], + /// The design's rows, built once per change. + table: &'a RowPlan, order: &'a [TimelineItem], - /// The rows to draw (`drawn_rows`). - plan: &'a HistoryRows, marker: usize, + /// The features picked in the design, collected once for the column. + selected: std::collections::HashSet, + /// Each feature's result, found once for the column. + results: std::collections::HashMap, /// Find in Timeline: the feature to scroll into view this frame (#5850). reveal: Option, /// The feature whose body is under the pointer in the 3D view. @@ -1242,39 +1253,33 @@ fn vertical_features(app: &mut SolveApp, ui: &mut egui::Ui, names: bool) { }); let among_features = |y: f32| features_top.is_none_or(|top| y >= top); ui.spacing_mut().item_spacing.y = if names { t.space_2 } else { t.space_4 }; - let features: Vec<_> = app - .session - .doc - .features - .iter() - .map(|feature| { - (feature.id, feature.name.clone(), icon_of(&feature.kind), feature.suppressed, feature.kind.type_name(), TimelineKind::of(&feature.kind)) - }) - .collect(); - let groups = app.session.doc.timeline_groups.clone(); - let n = features.len(); - let marker = app.session.doc.marker.unwrap_or(n); // The narrow icon strip has no room for nesting: it keeps the creation order. let under_feature = names && nests(app); - let plan = drawn_rows(app, under_feature); - let order = visible_order(app, &features, &groups, &plan); - let first_indices: std::collections::BTreeMap> = - groups.iter().map(|group| (group.id, group_position(&groups, &features, group.id))).collect(); - let end_indices: std::collections::BTreeMap = groups - .iter() - .map(|group| (group.id, group_last_feature(&groups, &features, group.id).map_or(features.len(), |index| index.saturating_add(1)))) - .collect(); + let table = RowPlan::cached(app, ui, under_feature); + let folds = table.folds(app); + let order = table.order(ui, &folds); + let (features, groups, plan) = (&table.features, &table.groups, &table.rows); + let n = features.len(); + let marker = app.session.doc.marker.unwrap_or(n); let from_view = [crate::timeline::hovered_feature(app), crate::timeline::picked_feature(app)]; let drag_id = ui.id().with("tl_vertical_drag"); let mut rows = Rows { sketch_states: crate::sketch_state::SketchStates::of(app), names, under_feature, - features: &features, - groups: &groups, + table: &table, order: &order, - plan: &plan, marker, + selected: app + .session + .selection + .iter() + .filter_map(|selection| match selection { + solidcraft_engine::Sel::Feature { id } => Some(*id), + _ => None, + }) + .collect(), + results: crate::timeline::result_index(&app.session.model.results), reveal: app.tree.timeline_reveal.take(), from_view, linked_hover: app.viewport.hover_feature.filter(|id| plan.referenced(*id)), @@ -1289,42 +1294,44 @@ fn vertical_features(app: &mut SolveApp, ui: &mut egui::Ui, names: bool) { let mut previous_visible_group = None; let mut marker_y = f32::INFINITY; - for (index, (id, ..)) in features.iter().enumerate() { - let group_id = group_for(&groups, *id); - let mut at_index: Vec<_> = groups.iter().filter(|group| first_indices.get(&group.id).copied().flatten() == Some(index)).collect(); - at_index.sort_by_key(|group| group_path(&groups, group.id)); + for index in 0..n { + let group_id = table.group_of.get(index).copied().flatten(); + let at_index = table.starting.get(index).map_or(&[][..], Vec::as_slice); let marker_before_group = index == marker && !at_index.is_empty(); if marker_before_group { - let group = at_index.first().and_then(|group| group.parent); + let group = at_index.first().and_then(|group| groups.get(*group)).and_then(|group| group.parent); let indent_group = previous_visible_group.filter(|previous| Some(*previous) != group); slots.push(VerticalSlot { y: ui.cursor().top(), position: index, group, indent_group }); marker_y = vertical_marker(app, ui, &mut rows.drag); } let mut rendered_group_header = false; - for group in at_index { - if group_ancestors_are_open(app, &groups, group.id) && shown_groups.insert(group.id) { + for &at in at_index { + let Some(group) = groups.get(at) else { continue }; + if table.ancestors_open(&folds, group.id) && shown_groups.insert(group.id) { vertical_group_row( app, names, ui, group, - &groups, + &table, + table.contents.get(at).map_or(&[][..], Vec::as_slice), + table.joints.get(at).map_or(&[][..], Vec::as_slice), &order, &mut rows.drag, - group_depth(&groups, group.id), + table.depth.get(at).copied().unwrap_or(0), index, - end_indices.get(&group.id).copied().unwrap_or(index), + table.end.get(at).copied().unwrap_or(index), previous_visible_group, &mut slots, &mut folder_drop_zones, ); - if group_first_feature(&groups, &features, group.id).is_none() { - folder_joint_rows(app, ui, group, &groups); + if table.first.get(at).copied().flatten().is_none() { + folder_joint_rows(app, ui, group, groups); } rendered_group_header = true; } } - let group_open = group_id.is_none_or(|id| group_is_open(app, &groups, id)); + let group_open = group_id.is_none_or(|id| table.is_open(&folds, id)); let indent_group = (!rendered_group_header).then_some(previous_visible_group).flatten().filter(|previous| Some(*previous) != group_id); if index == marker && !marker_before_group { slots.push(VerticalSlot { y: ui.cursor().top(), position: index, group: group_open.then_some(group_id).flatten(), indent_group }); @@ -1333,36 +1340,35 @@ fn vertical_features(app: &mut SolveApp, ui: &mut egui::Ui, names: bool) { // A sketch listed under a feature is drawn after that feature's row. if group_open && let Some(row) = plan.row_at(index) { slots.push(VerticalSlot { y: ui.cursor().top(), position: row.slot.min(index), group: group_id, indent_group }); - let indent = group_id.map(|id| (group_depth(&groups, id) + 1) as f32 * FOLDER_INDENT).unwrap_or(0.0); + let indent = group_id.map(|id| (table.depth_of(id) + 1) as f32 * FOLDER_INDENT).unwrap_or(0.0); feature_row(app, ui, &mut rows, index, indent, RowKind::Own); previous_visible_group = group_id; } - let mut ending: Vec<_> = groups.iter().filter(|group| group_last_feature(&groups, &features, group.id) == Some(index)).collect(); - ending.sort_by_key(|group| std::cmp::Reverse(group_depth(&groups, group.id))); - for group in ending { - folder_joint_rows(app, ui, group, &groups); + for group in table.ending.get(index).map_or(&[][..], Vec::as_slice).iter().filter_map(|group| groups.get(*group)) { + folder_joint_rows(app, ui, group, groups); } } - let mut trailing: Vec<_> = groups.iter().filter(|group| !shown_groups.contains(&group.id)).collect(); - trailing.sort_by_key(|group| (first_indices.get(&group.id).copied().flatten().unwrap_or(usize::MAX), group_path(&groups, group.id))); - for group in trailing { - if group_ancestors_are_open(app, &groups, group.id) && shown_groups.insert(group.id) { + for &at in &table.trailing { + let Some(group) = groups.get(at) else { continue }; + if !shown_groups.contains(&group.id) && table.ancestors_open(&folds, group.id) && shown_groups.insert(group.id) { vertical_group_row( app, names, ui, group, - &groups, + &table, + table.contents.get(at).map_or(&[][..], Vec::as_slice), + table.joints.get(at).map_or(&[][..], Vec::as_slice), &order, &mut rows.drag, - group_depth(&groups, group.id), + table.depth.get(at).copied().unwrap_or(0), n, n, previous_visible_group, &mut slots, &mut folder_drop_zones, ); - folder_joint_rows(app, ui, group, &groups); + folder_joint_rows(app, ui, group, groups); } } slots.push(VerticalSlot { y: ui.cursor().top(), position: n, group: None, indent_group: previous_visible_group }); @@ -1386,8 +1392,8 @@ fn vertical_features(app: &mut SolveApp, ui: &mut egui::Ui, names: bool) { let folder_target = match active { Drag::Items(items) => { let panel_left = ui.min_rect().left(); - vertical_drop_group(pointer, slot, &folder_drop_zones, &groups, panel_left, drag_x(items, pointer.x), items.source_group) - .filter(|target| valid_folder_target(items, &groups, *target)) + vertical_drop_group(pointer, slot, &folder_drop_zones, groups, panel_left, drag_x(items, pointer.x), items.source_group) + .filter(|target| valid_folder_target(items, groups, *target)) } Drag::Marker => None, }; @@ -1397,7 +1403,7 @@ fn vertical_features(app: &mut SolveApp, ui: &mut egui::Ui, names: bool) { if let Some(rect) = hovered_folder { ui.painter().rect_stroke(rect.shrink(1.0), t.radius_sm.saturating_sub(1), Stroke::new(2.0, t.sketch_accent), egui::StrokeKind::Inside); } else { - let left = vertical_drop_bar_left(ui.min_rect().left(), folder_target, &groups); + let left = vertical_drop_bar_left(ui.min_rect().left(), folder_target, groups); crate::ui_kit::history_drop_bar( ui.painter(), Rect::from_min_max(pos2(left, slot.y), pos2(ui.max_rect().right() - 5.0, slot.y)), @@ -1418,10 +1424,10 @@ fn vertical_features(app: &mut SolveApp, ui: &mut egui::Ui, names: bool) { Drag::Items(items) => { let panel_left = ui.min_rect().left(); let folder_target = - vertical_drop_group(pointer, slot, &folder_drop_zones, &groups, panel_left, drag_x(&items, pointer.x), items.source_group); - let valid_target = folder_target.is_none_or(|target| valid_folder_target(&items, &groups, target)); + vertical_drop_group(pointer, slot, &folder_drop_zones, groups, panel_left, drag_x(&items, pointer.x), items.source_group); + let valid_target = folder_target.is_none_or(|target| valid_folder_target(&items, groups, target)); if valid_target { - drop_items(app, &plan, &items.features, &items.groups, folder_target, slot.position, slot.y < marker_y); + drop_items(app, plan, &items.features, &items.groups, folder_target, slot.position, slot.y < marker_y); } } } @@ -1441,16 +1447,15 @@ fn vertical_features(app: &mut SolveApp, ui: &mut egui::Ui, names: bool) { /// One feature's row at timeline position `index`, `indent` points in, and for a feature that /// sketches are listed under, those sketches' rows when its fold is open. fn feature_row(app: &mut SolveApp, ui: &mut egui::Ui, rows: &mut Rows, index: usize, indent: f32, row_kind: RowKind) { - let t = Tokens::get(); - let (features, names, plan, groups, order, marker) = (rows.features, rows.names, rows.plan, rows.groups, rows.order, rows.marker); - let Some((id, name, icon, suppressed, ty, kind)) = features.get(index) else { return }; + let (table, names) = (rows.table, rows.names); + let (features, plan) = (&table.features, &table.rows); + let Some((id, name, _, _, _, kind)) = features.get(index) else { return }; let id = *id; - let group_id = group_for(groups, id); + let group_id = table.group_of.get(index).copied().flatten(); let reference = matches!(row_kind, RowKind::Reference { .. }); let listed = (row_kind == RowKind::Own).then(|| plan.row_at(index)).flatten().map(|row| row.listed.as_slice()).filter(|listed| !listed.is_empty()); - let has_patterns = !reference && matches!(kind, TimelineKind::Sketch) && crate::sketch_pattern_tree::has_patterns(app, id); - let sketch_state = matches!(kind, TimelineKind::Sketch).then(|| rows.sketch_states.get(id)).flatten(); + let has_patterns = !reference && matches!(kind, TimelineKind::Sketch) && table.has_patterns.get(index).copied().unwrap_or(false); // One fold per row: a feature's listed sketches, or a sketch's patterns. let fold = match listed { Some(_) => Some((sketches_key(id), false)), @@ -1458,8 +1463,10 @@ fn feature_row(app: &mut SolveApp, ui: &mut egui::Ui, rows: &mut Rows, index: us }; let fold_open = fold.as_ref().is_some_and(|(key, default)| crate::browser::is_open(app, key, *default)); let style = if names { crate::ui_kit::HistoryStyle::Names } else { crate::ui_kit::HistoryStyle::Icons }; - let sense = if reference { Sense::click() } else { Sense::click_and_drag() }; - let (row, response) = ui.allocate_exact_size(vec2(ui.available_width(), style.height()), sense); + // `allocate_exact_size` in two steps, so a row outside the view takes its place and no more. + let size = vec2(ui.available_width(), style.height()); + let (row_id, frame) = ui.allocate_space(size); + let row = ui.layout().align_size_within_rect(size, frame); let rect = if style == crate::ui_kit::HistoryStyle::Icons { Rect::from_center_size(row.center(), vec2(style.height(), style.height())) } else { row }; let parent = match row_kind { @@ -1477,8 +1484,93 @@ fn feature_row(app: &mut SolveApp, ui: &mut egui::Ui, rows: &mut Rows, index: us } } let fold_rect = Rect::from_min_size(rect.left_top() + vec2(indent, 0.0), vec2(FOLD_COLUMN, rect.height())); + if fold.is_some() { + let handle = if listed.is_some() { "timeline-sketches-fold" } else { "timeline-sketch-fold" }; + crate::scenario::publish_handle(&format!("{handle}:{name}"), fold_rect.center()); + } + // A row scrolled out of the History is neither drawn nor interactive: a long history drew + // and hit-tested every row each frame. Its slot still places the marker and drops, and its + // handles stay published. One being found, dragged or focused, or under an open menu, stays. + let shown = ui.clip_rect().intersects(frame) + || rows.reveal == Some(id) + || ui.ctx().is_being_dragged(row_id) + || ui.memory(|memory| memory.has_focus(row_id)) + || egui::Popup::is_any_open(ui.ctx()) + || draw_every_row(); + if shown { + let sense = if reference { Sense::click() } else { Sense::click_and_drag() }; + let mut response = ui.interact(frame, row_id, sense); + response.set_intrinsic_size(size); + let places = RowPlaces { rect, fold_rect, indent }; + feature_row_body(app, ui, rows, index, row_kind, fold.as_ref(), fold_open, places, response); + } + if names && has_patterns && fold_open { + let depth = group_id.map_or(1, |group| table.depth_of(group) + 2) + usize::from(row_kind == RowKind::Nested); + crate::sketch_pattern_tree::rows(app, ui, id, depth, "timeline", None, false); + } + if let Some(listed) = listed.filter(|_| names && fold_open) { + for child in listed { + let child_kind = if child.reference { RowKind::Reference { parent: index } } else { RowKind::Nested }; + feature_row(app, ui, rows, child.index, indent + FOLDER_INDENT, child_kind); + } + } +} + +#[cfg(test)] +thread_local! { + /// Tests compare the History drawn row by row with the one that leaves out what is out of view. + static DRAW_EVERY_ROW: std::cell::Cell = const { std::cell::Cell::new(false) }; + /// How many feature rows were drawn and made interactive. + static ROWS_DRAWN: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +#[cfg(test)] +fn draw_every_row() -> bool { + DRAW_EVERY_ROW.with(std::cell::Cell::get) +} + +#[cfg(not(test))] +fn draw_every_row() -> bool { + false +} + +/// Where a feature row in view is drawn: its chip, its fold arrow, and how far it is indented. +#[derive(Clone, Copy)] +struct RowPlaces { + rect: Rect, + fold_rect: Rect, + indent: f32, +} + +/// The drawing and interaction of a feature row that is in view. +fn feature_row_body( + app: &mut SolveApp, + ui: &mut egui::Ui, + rows: &mut Rows, + index: usize, + row_kind: RowKind, + fold: Option<&(String, bool)>, + fold_open: bool, + places: RowPlaces, + response: egui::Response, +) { + let RowPlaces { rect, fold_rect, indent } = places; + #[cfg(test)] + ROWS_DRAWN.with(|drawn| drawn.set(drawn.get() + 1)); + let t = Tokens::get(); + let (table, names, order, marker) = (rows.table, rows.names, rows.order, rows.marker); + let (features, plan, groups) = (&table.features, &table.rows, &table.groups); + let Some((id, name, icon, suppressed, ty, kind)) = features.get(index) else { return }; + let id = *id; + let group_id = table.group_of.get(index).copied().flatten(); + let reference = matches!(row_kind, RowKind::Reference { .. }); + let sketch_state = matches!(kind, TimelineKind::Sketch).then(|| rows.sketch_states.get(id)).flatten(); let on_fold = fold.is_some() && response.interact_pointer_pos().is_some_and(|p| fold_rect.contains(p)); - let result = app.session.model.result(id); + // An index that no longer holds this id (a command run from an earlier row) is searched for. + let result = rows + .results + .get(&id) + .and_then(|at| app.session.model.results.get(*at).filter(|result| result.id == id).or_else(|| app.session.model.result(id))); let elapsed = result.map(|r| r.ms); let error = result.and_then(|result| result.error.clone()); let warning = result.and_then(|result| result.warning.clone()); @@ -1486,13 +1578,13 @@ fn feature_row(app: &mut SolveApp, ui: &mut egui::Ui, rows: &mut Rows, index: us RowKind::Reference { parent } => parent >= marker, RowKind::Own | RowKind::Nested => index >= marker, }; - let selected = - app.session.selection.iter().any(|selection| matches!(selection, solidcraft_engine::Sel::Feature { id: selected } if *selected == id)); + let selected = rows.selected.contains(&id); // Folder and sketch-pattern disclosure targets retain their original handles. Listing sketches // under their features keeps the fold column on every row, so the icons line up. let column = if rows.under_feature || fold.is_some() { FOLD_COLUMN } else { 0.0 }; let inset = if names { indent + column } else { 0.0 }; let chip = Rect::from_min_max(rect.min + vec2(inset.min(rect.width()), 0.0), rect.max); + let style = if names { crate::ui_kit::HistoryStyle::Names } else { crate::ui_kit::HistoryStyle::Icons }; // Dragged features take the colour of their drop bar, with the sketches listed under them. let dragged = matches!(&rows.drag, Some(Drag::Items(items)) if items.features.contains(&id) || (row_kind == RowKind::Nested && plan.host(id).is_some_and(|host| items.features.contains(&host)))); @@ -1513,8 +1605,6 @@ fn feature_row(app: &mut SolveApp, ui: &mut egui::Ui, rows: &mut Rows, index: us .paint(ui.painter(), chip, style); if fold.is_some() { crate::ui_kit::disclosure(ui.painter(), fold_rect.center(), fold_open, t.text_dim); - let handle = if listed.is_some() { "timeline-sketches-fold" } else { "timeline-sketch-fold" }; - crate::scenario::publish_handle(&format!("{handle}:{name}"), fold_rect.center()); } if response.hovered() { rows.hovered = Some(id); @@ -1524,7 +1614,7 @@ fn feature_row(app: &mut SolveApp, ui: &mut egui::Ui, rows: &mut Rows, index: us let origin_x = drag_press_x(&response, rect.center().x); rows.drag = Some(Drag::Items(DragItems { features, ..selected_drag_items(app, origin_x, group_id) })); } - let listed_under = plan.host(id).and_then(|host| features.iter().find(|feature| feature.0 == host)).map(|feature| feature.1.as_str()); + let listed_under = plan.host(id).and_then(|host| table.name(host)); let mut tooltip = egui::Tooltip::for_enabled(&response); tooltip.popup = tooltip.popup.align(egui::RectAlign::RIGHT_START); tooltip.show(|ui| { @@ -1555,7 +1645,7 @@ fn feature_row(app: &mut SolveApp, ui: &mut egui::Ui, rows: &mut Rows, index: us } }); if response.clicked() && on_fold { - if let Some((key, default)) = &fold { + if let Some((key, default)) = fold { crate::browser::toggle(app, key, *default); } } else if response.clicked() && !crate::timeline::pick_feature_into_dialog(app, id, ui.input(|i| i.modifiers)) { @@ -1605,16 +1695,6 @@ fn feature_row(app: &mut SolveApp, ui: &mut egui::Ui, rows: &mut Rows, index: us if edit { rows.edit = Some(id); } - if names && has_patterns && fold_open { - let depth = group_id.map_or(1, |group| group_depth(groups, group) + 2) + usize::from(row_kind == RowKind::Nested); - crate::sketch_pattern_tree::rows(app, ui, id, depth, "timeline", None, false); - } - if let Some(listed) = listed.filter(|_| names && fold_open) { - for child in listed { - let child_kind = if child.reference { RowKind::Reference { parent: index } } else { RowKind::Nested }; - feature_row(app, ui, rows, child.index, indent + FOLDER_INDENT, child_kind); - } - } } #[cfg(test)] @@ -3141,4 +3221,331 @@ mod tests { assert!(handle_at(&pattern_row).is_none(), "the listed sketch folds its patterns"); assert!(handle_at("timeline:Patterned").is_some()); } + + /// Folders that nest three deep, a folder with only joints, a stale feature id, a parent that + /// is no folder any more, and a feature listed in two folders: the cases the plan's tables + /// have to place as the per-frame searches did. + fn tangled_folders(h: &mut Harness) { + let ids: Vec = h.app.session.doc.features.iter().map(|feature| feature.id).collect(); + let group = |id: u64, name: &str, parent: Option, features: Vec, joints: Vec| solidcraft_engine::doc::TimelineGroup { + id, + name: name.into(), + parent, + features, + joints, + }; + let doc = h.app.session.doc_mut(); + doc.timeline_groups = vec![ + group(9001, "Outer", None, vec![ids[1], ids[2]], vec![]), + group(9002, "Middle", Some(9001), vec![ids[3], 777_777], vec![]), + group(9003, "Inner", Some(9002), vec![ids[4]], vec![55]), + group(9004, "Joints only", Some(9001), vec![], vec![56, 57]), + group(9005, "Loose", None, vec![ids[6], ids[7]], vec![]), + group(9006, "Orphan", Some(4242), vec![ids[8]], vec![]), + group(9007, "Twice", None, vec![ids[7], ids[9]], vec![]), + group(9008, "Empty", Some(9005), vec![], vec![]), + ]; + h.app.session.revision += 1; + } + + /// The plan's tables against the per-frame searches they replaced, in every fold state. + fn assert_plan_matches_the_searches(h: &mut Harness, under_feature: bool) { + let table = RowPlan::of(&h.app, under_feature); + let app = &h.app; + let (features, groups) = (&table.features, &table.groups); + let n = features.len(); + assert_eq!(table.rows, drawn_rows(app, under_feature)); + for (index, feature) in features.iter().enumerate() { + assert_eq!(table.group_of[index], group_for(groups, feature.0), "{}", feature.1); + let sketch = matches!(feature.5, TimelineKind::Sketch); + assert_eq!(sketch && table.has_patterns[index], sketch && crate::sketch_pattern_tree::has_patterns(app, feature.0)); + } + for (at, group) in groups.iter().enumerate() { + assert_eq!(table.depth[at], group_depth(groups, group.id), "{}", group.name); + assert_eq!(table.first[at], group_first_feature(groups, features, group.id), "{}", group.name); + assert_eq!(table.position[at], group_position(groups, features, group.id), "{}", group.name); + assert_eq!(table.end[at], group_last_feature(groups, features, group.id).map_or(n, |index| index + 1), "{}", group.name); + assert_eq!(table.contents[at], folder_features(app, &[group.id]), "{}", group.name); + let joints: Vec = groups + .iter() + .filter(|candidate| candidate.id == group.id || group_is_descendant(groups, candidate.id, group.id)) + .flat_map(|group| group.joints.iter().copied()) + .collect(); + assert_eq!(table.joints[at], joints, "{}", group.name); + } + let index_of = |group: &solidcraft_engine::doc::TimelineGroup| groups.iter().position(|candidate| std::ptr::eq(candidate, group)).unwrap(); + for index in 0..n { + let mut at_index: Vec<_> = groups.iter().filter(|group| group_position(groups, features, group.id) == Some(index)).collect(); + at_index.sort_by_key(|group| group_path(groups, group.id)); + assert_eq!(table.starting[index], at_index.into_iter().map(index_of).collect::>(), "headers before {index}"); + let mut ending: Vec<_> = groups.iter().filter(|group| group_last_feature(groups, features, group.id) == Some(index)).collect(); + ending.sort_by_key(|group| std::cmp::Reverse(group_depth(groups, group.id))); + assert_eq!(table.ending[index], ending.into_iter().map(index_of).collect::>(), "folders ending at {index}"); + } + let mut trailing: Vec<_> = groups.iter().collect(); + trailing.sort_by_key(|group| (group_position(groups, features, group.id).unwrap_or(usize::MAX), group_path(groups, group.id))); + assert_eq!(table.trailing, trailing.into_iter().map(index_of).collect::>()); + // Every combination of closed folders (and a stale key for the missing parent), with + // and without the listed sketches shown. + let keys: Vec = groups.iter().map(|group| group.id).chain([4242]).map(|id| format!("timeline_group:{id}")).collect(); + let hosts: Vec = table.rows.rows.iter().filter(|row| !row.listed.is_empty()).map(|row| sketches_key(row.feature)).collect(); + let kept = (h.app.tree.collapsed.clone(), h.app.tree.expanded.clone()); + for closed in 0..(1u32 << keys.len()) { + for open_hosts in [false, true] { + let app = &mut h.app; + app.tree.collapsed = keys.iter().enumerate().filter(|(bit, _)| closed & (1 << bit) != 0).map(|(_, key)| key.clone()).collect(); + app.tree.expanded = if open_hosts { hosts.iter().cloned().collect() } else { Default::default() }; + let folds = table.folds(app); + assert_eq!(table.visible_order(&folds), visible_order(app, features, groups, &table.rows), "closed {closed:b}, hosts {open_hosts}"); + for group in groups { + assert_eq!(table.is_open(&folds, group.id), group_is_open(app, groups, group.id), "{}", group.name); + assert_eq!(table.ancestors_open(&folds, group.id), group_ancestors_are_open(app, groups, group.id), "{}", group.name); + } + } + } + (h.app.tree.collapsed, h.app.tree.expanded) = kept; + } + + #[test] + fn the_row_plan_places_folders_and_rows_as_the_per_frame_searches_did() { + let mut h = nested_history(); + square(&mut h, "Profile", 0.0); + extrude(&mut h, "Profile", "Block", "new"); + square(&mut h, "Pocket", 20.0); + extrude(&mut h, "Pocket", "Second", "new"); + for (k, x) in [40.0, 60.0, 80.0].into_iter().enumerate() { + let name = format!("Sketch {k}"); + square(&mut h, &name, x); + extrude(&mut h, &name, &format!("Body {k}"), "new"); + } + h.app.run("timeline.roll_to", json!({"position": 7})).unwrap(); + tangled_folders(&mut h); + h.settle(); + for under_feature in [false, true] { + assert_plan_matches_the_searches(&mut h, under_feature); + } + // Without folders, and with an empty design. + h.app.session.doc_mut().timeline_groups.clear(); + assert_plan_matches_the_searches(&mut h, true); + assert_plan_matches_the_searches(&mut Harness::new(), false); + } + + #[test] + fn the_kept_row_plan_is_the_one_built_afresh_after_every_change() { + let mut h = nested_history(); + square(&mut h, "Profile", 0.0); + extrude(&mut h, "Profile", "Block", "new"); + square(&mut h, "Pocket", 20.0); + extrude(&mut h, "Pocket", "Second", "new"); + h.settle(); + let check = |h: &mut Harness, what: &str| { + h.frames(2); + let kept = RowPlan::kept(&h.ctx).expect("the History drew a plan"); + assert_eq!(kept.summary(), RowPlan::of(&h.app, kept.under_feature()).summary(), "{what}"); + }; + check(&mut h, "opened"); + let id = |h: &Harness, name: &str| h.app.session.doc.find_feature(name).unwrap().id; + let (block, second) = (id(&h, "Block"), id(&h, "Second")); + assert!(RowPlan::kept(&h.ctx).unwrap().under_feature(), "sketches are listed under their features"); + let steps: [(&str, serde_json::Value); 6] = [ + ("timeline.group", json!({"name": "Housing", "features": ["Profile", "Block"]})), + ("timeline.rename", json!({"feature": second, "name": "Lid"})), + ("timeline.suppress", json!({"feature": block})), + ("timeline.roll_to", json!({"position": 2})), + ("timeline.roll_to", json!({})), + ("timeline.move_many", json!({"features": [block], "position": 4, "group": null, "active": true})), + ]; + for (command, params) in steps { + h.app.run(command, params.clone()).unwrap_or_else(|e| panic!("{command} {params}: {e}")); + check(&mut h, command); + } + h.app.run("edit.undo", json!({})).unwrap(); + check(&mut h, "undo"); + // An edit in place without a revision, and the preference. + h.app.session.doc_mut().marker = Some(1); + check(&mut h, "marker set in place"); + h.app.preferences.history_sketches = crate::prefs::HistorySketches::InOrder; + check(&mut h, "sketches in order"); + // Another design in the same window. + let other = h.app.session.doc.clone(); + h.app.session = solidcraft_engine::Session::new(solidcraft_engine::doc::Document::default()); + check(&mut h, "new design"); + h.app.session = solidcraft_engine::Session::new((*other).clone()); + check(&mut h, "the first design again"); + } + + /// A history taller than its panel, drawn with every row and with the rows out of view left + /// out: the same places, the same text in view, and only the rows in view painted. + #[test] + fn rows_out_of_view_keep_their_places_and_what_is_in_view_draws_the_same() { + let mut h = Harness::new(); + h.app.home.open = false; + h.app.preferences.timeline_left = true; + h.app.preferences.timeline_left_width = 240.0; + h.call("ui.resize", json!({"width": 1200, "height": 700})); + for k in 0..60 { + h.app + .run( + "solid.box", + json!({"length": 5, "width": 5, "height": 5, "corner": [k * 10, 0, 0], "operation": "new_body", "name": format!("Box {k}")}), + ) + .unwrap(); + } + let ids: Vec = h.app.session.doc.features.iter().map(|feature| feature.id).collect(); + h.app.run("timeline.group", json!({"name": "Front", "features": ids[5..15]})).unwrap(); + h.app.run("timeline.roll_to", json!({"position": 40})).unwrap(); + h.settle(); + let draw = |h: &mut Harness, every: bool| { + DRAW_EVERY_ROW.with(|cell| cell.set(every)); + h.frames(2); + ROWS_DRAWN.with(|drawn| drawn.set(0)); + h.frame(); + let painted = ROWS_DRAWN.with(std::cell::Cell::get); + DRAW_EVERY_ROW.with(|cell| cell.set(false)); + let card = h.app.shell.history.expect("history bounds"); + let places: Vec<_> = (0..60).map(|k| crate::scenario::rect_of(&format!("history:feature:Box {k}"))).collect(); + // The text that shows in the History: inside the card and inside its clip. + let texts: Vec = h + .shapes + .iter() + .filter_map(|clipped| match &clipped.shape { + egui::Shape::Text(text) => Some((clipped.clip_rect, text)), + _ => None, + }) + .filter(|(clip, text)| text.visual_bounding_rect().intersects(card.intersect(*clip))) + .map(|(_, text)| text.galley.text().to_string()) + .collect(); + (places, texts, painted) + }; + for reveal in [None, Some(ids[30]), Some(ids[59])] { + h.app.tree.timeline_reveal = reveal; + // A second for the scroll to arrive. + h.frames(60); + let (every_places, every_texts, every_painted) = draw(&mut h, true); + let (places, texts, painted) = draw(&mut h, false); + assert!(places.iter().all(Option::is_some), "every row keeps its handle"); + assert_eq!(places, every_places, "{reveal:?}"); + assert_eq!(texts, every_texts, "{reveal:?}"); + assert!(texts.iter().any(|text| text.starts_with("Box ")), "{reveal:?}: rows are in view"); + assert_eq!(every_painted, 60, "{reveal:?}"); + assert!(painted < 40, "{reveal:?}: {painted} of {every_painted} rows drawn"); + } + // A drag among the rows in view still moves a feature, with rows out of view left out. + h.app.tree.timeline_reveal = Some(ids[0]); + h.frames(60); + let target = handle_at("timeline:Box 3").unwrap(); + h.step(&json!({"drag": [{"handle": "timeline:Box 1"}, [target.x, target.y + 8.0]]})).unwrap(); + let names = feature_names(&h); + assert_eq!(names[..4], ["Box 0", "Box 2", "Box 3", "Box 1"], "{names:?}"); + assert_eq!(names.len(), 60); + } + + /// Frame time of the vertical History alone on a large design (P5 measurement, run by hand): + /// `SOLIDCRAFT_HISTORY_BENCH= cargo test --release -p solidcraft-ui-egui --lib + /// bench_history_panel -- --ignored --nocapture`. Prints one JSON line per case. The folders + /// cases add 120 folders of 20 features (every fourth nested in the one before). + #[test] + #[ignore = "timing, run by hand with SOLIDCRAFT_HISTORY_BENCH"] + fn bench_history_panel_frames() { + let Some(path) = std::env::var_os("SOLIDCRAFT_HISTORY_BENCH") else { return }; + let frames: usize = std::env::var("SOLIDCRAFT_HISTORY_BENCH_FRAMES").ok().and_then(|n| n.parse().ok()).unwrap_or(40); + let mut h = Harness::new(); + h.app.home.open = false; + h.app.preferences.timeline_left = true; + h.app.run("doc.open", json!({"path": path.to_string_lossy()})).unwrap(); + let ctx = crate::fonts::test_context(); + let size = egui::vec2(300.0, 1000.0); + let mut time = 0.0; + let mut run = |app: &mut SolveApp, events: Vec| { + time += 1.0 / 60.0; + crate::scenario::clear_handles(); + let raw = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)), + time: Some(time), + focused: true, + events, + ..Default::default() + }; + let start = std::time::Instant::now(); + let mut out = ctx.run_ui(raw, |ui| left_timeline(app, ui)); + let ms = start.elapsed().as_secs_f64() * 1000.0; + out.textures_delta.clear(); + ms + }; + let report = |case: &str, mut ms: Vec| { + ms.sort_by(f64::total_cmp); + let median = ms[ms.len() / 2]; + println!("{}", json!({"case": case, "median_ms": median, "min_ms": ms[0], "max_ms": ms[ms.len() - 1], "frames": ms.len()})); + }; + let features = h.app.session.doc.features.len(); + println!("{}", json!({"features": features, "groups": h.app.session.doc.timeline_groups.len()})); + let order: Vec = h.app.session.doc.features.iter().map(|f| f.id).collect(); + let middle = h.app.session.doc.features[features / 2].id; + let middle_name = h.app.session.doc.features[features / 2].name.clone(); + let moves = |y0: f32, i: usize| vec![egui::Event::PointerMoved(egui::pos2(150.0, y0 + (i % 7) as f32 * 3.0))]; + // SOLIDCRAFT_HISTORY_BENCH_CASES: "plain", "folders" or both (the default). + let cases = std::env::var("SOLIDCRAFT_HISTORY_BENCH_CASES").unwrap_or_default(); + for folders in [false, true] { + if !cases.is_empty() && cases != if folders { "folders" } else { "plain" } { + continue; + } + if folders { + // 120 folders of 20 features each, every fourth nested in the folder before it. + let ids: Vec = h.app.session.doc.features.iter().map(|f| f.id).collect(); + let doc = h.app.session.doc_mut(); + let mut next = doc.next_id.max(1_000_000); + let mut previous = None; + for (k, chunk) in ids.chunks(20).take(120).enumerate() { + let id = next; + next += 1; + let parent = if k % 4 == 3 { previous } else { None }; + doc.timeline_groups.push(solidcraft_engine::doc::TimelineGroup { + id, + name: format!("Folder {k}"), + parent, + features: chunk.to_vec(), + joints: Vec::new(), + }); + previous = Some(id); + } + doc.next_id = next; + h.app.session.revision += 1; + } + let tag = if folders { "folders" } else { "plain" }; + for _ in 0..3 { + run(&mut h.app, Vec::new()); + } + report(&format!("{tag}/top idle"), (0..frames).map(|_| run(&mut h.app, Vec::new())).collect()); + report(&format!("{tag}/top hover"), (0..frames).map(|i| run(&mut h.app, moves(300.0, i))).collect()); + h.app.tree.timeline_reveal = Some(middle); + for _ in 0..3 { + run(&mut h.app, Vec::new()); + } + report(&format!("{tag}/middle idle"), (0..frames).map(|_| run(&mut h.app, Vec::new())).collect()); + report(&format!("{tag}/middle hover"), (0..frames).map(|i| run(&mut h.app, moves(400.0, i))).collect()); + // Drag the middle feature: press on it, then move the pointer each frame. + crate::scenario::clear_handles(); + run(&mut h.app, Vec::new()); + let at = handle_at(&format!("timeline:{middle_name}")).unwrap_or(egui::pos2(150.0, 500.0)); + run(&mut h.app, vec![egui::Event::PointerMoved(at)]); + run( + &mut h.app, + vec![egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }], + ); + let drag: Vec = + (0..frames).map(|i| run(&mut h.app, vec![egui::Event::PointerMoved(at + egui::vec2(0.0, 12.0 + (i % 5) as f32 * 4.0))])).collect(); + report(&format!("{tag}/middle drag"), drag); + // Let go with the pointer gone in the same frame: the drop has no place, so nothing moves. + run( + &mut h.app, + vec![ + egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }, + egui::Event::PointerGone, + ], + ); + run(&mut h.app, Vec::new()); + } + let after: Vec = h.app.session.doc.features.iter().map(|f| f.id).collect(); + assert_eq!(after, order, "measuring moved nothing"); + } } diff --git a/crates/ui-egui/src/timeline_left/plan.rs b/crates/ui-egui/src/timeline_left/plan.rs new file mode 100644 index 0000000000..5eefcce74e --- /dev/null +++ b/crates/ui-egui/src/timeline_left/plan.rs @@ -0,0 +1,452 @@ +//! The vertical History's row plan: which folder headers, feature rows and joint rows come where. +//! +//! Working this out from the design took every frame several passes over all features for each +//! folder (and a search for each folder's last feature at every row), which a long history with +//! folders paid for on every repaint, even with the pointer only moving. Everything here follows +//! from the design alone, so it is built once per design change ([`RowPlan::of`]) and kept in the +//! egui context; what the open folders and folds change ([`RowPlan::order`]) is kept beside it +//! until one of them opens or closes. The functions it replaced live on in the tests as the +//! reference it is compared with. + +use std::collections::{BTreeSet, HashMap, HashSet}; +use std::sync::Arc; + +use solidcraft_engine::doc::TimelineGroup; + +use super::{TimelineItem, group_path, sketches_key}; +use crate::SolveApp; +use crate::history_rows::{HistoryFeature, HistoryRows}; +use crate::timeline::{TimelineKind, icon_of}; + +/// A feature as its row draws it: id, name, icon, suppressed, type name and kind. +pub(super) type FeatureInfo = (u64, String, &'static str, bool, &'static str, TimelineKind); + +/// What a plan was built from. The design changes through commands, which count the session's +/// revision up; the marker and the counts guard edits made in place without one. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct PlanKey { + session: u64, + revision: u64, + document: usize, + marker: Option, + features: usize, + groups: usize, + under_feature: bool, +} + +impl PlanKey { + fn of(app: &SolveApp, under_feature: bool) -> Self { + let doc = &app.session.doc; + Self { + session: app.session.uid, + revision: app.session.revision, + document: Arc::as_ptr(doc) as usize, + marker: doc.marker, + features: doc.features.len(), + groups: doc.timeline_groups.len(), + under_feature, + } + } +} + +/// The folders and folds that are open: the visible order follows from these and the plan. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(super) struct Folds { + /// Folders closed by the person, by id. + closed: HashSet, + /// Features whose listed sketches are shown. + open_hosts: BTreeSet, +} + +impl Folds { + pub(super) fn is_closed(&self, group: u64) -> bool { + self.closed.contains(&group) + } +} + +pub(super) struct RowPlan { + key: PlanKey, + pub features: Vec, + pub groups: Vec, + /// The rows to draw (`drawn_rows`). + pub rows: HistoryRows, + /// Each feature id's first timeline position. + index_of: HashMap, + /// The folder each feature is in (`group_for`), by timeline position. + pub group_of: Vec>, + /// Whether the sketch at each timeline position has patterns. + pub has_patterns: Vec, + /// The first folder with each id: the one `groups.iter().find` would give. + by_id: HashMap, + /// By folder index: its nesting depth. + pub depth: Vec, + /// By folder index: the position of its first own or nested feature. + pub first: Vec>, + /// By folder index: where its header goes, its own first feature or that of the nearest + /// ancestor that has one. + pub position: Vec>, + /// By folder index: the position after its last own or nested feature (the end with none). + pub end: Vec, + /// By timeline position: the folders whose header goes before it, outermost first. + pub starting: Vec>, + /// By timeline position: the folders whose last feature it is, innermost first. + pub ending: Vec>, + /// Every folder in the order of headers left over after the features. + pub trailing: Vec, + /// By folder index: its own and nested features in timeline order (`folder_features`). + pub contents: Vec>, + /// By folder index: its own and nested folders' joints. + pub joints: Vec>, + /// Each folder and parent id, with its fold key in the tree state. + fold_keys: Vec<(u64, String)>, + /// Features with sketches listed under them, and their fold keys. + host_keys: Vec<(u64, String)>, +} + +impl RowPlan { + /// The plan for the design as it is, kept until it changes. + pub(super) fn cached(app: &SolveApp, ui: &egui::Ui, under_feature: bool) -> Arc { + let id = egui::Id::new("sc_history_row_plan"); + let key = PlanKey::of(app, under_feature); + if let Some(plan) = ui.data(|data| data.get_temp::>(id)).filter(|plan| plan.key == key) { + return plan; + } + let plan = Arc::new(Self::of(app, under_feature)); + ui.data_mut(|data| data.insert_temp(id, plan.clone())); + plan + } + + /// Build the plan from the design. + pub(super) fn of(app: &SolveApp, under_feature: bool) -> Self { + let doc = &app.session.doc; + let features: Vec = doc + .features + .iter() + .map(|feature| { + ( + feature.id, + feature.name.clone(), + icon_of(&feature.kind), + feature.suppressed, + feature.kind.type_name(), + TimelineKind::of(&feature.kind), + ) + }) + .collect(); + let groups = doc.timeline_groups.clone(); + let rows = if under_feature { + let listed: Vec = doc.features.iter().map(HistoryFeature::of).collect(); + crate::history_rows::rows(&listed, &groups, doc.marker, true) + } else { + let sketch = |feature: &solidcraft_engine::doc::Feature| matches!(feature.kind, solidcraft_engine::doc::FeatureKind::Sketch { .. }); + HistoryRows::in_order(doc.features.iter().map(|feature| (feature.id, sketch(feature)))) + }; + let mut index_of = HashMap::with_capacity(features.len()); + for (index, feature) in features.iter().enumerate() { + index_of.entry(feature.0).or_insert(index); + } + let mut by_id = HashMap::with_capacity(groups.len()); + let mut member_of = HashMap::new(); + let mut children: HashMap> = HashMap::new(); + for (index, group) in groups.iter().enumerate() { + by_id.entry(group.id).or_insert(index); + for feature in &group.features { + member_of.entry(*feature).or_insert(group.id); + } + if let Some(parent) = group.parent { + children.entry(parent).or_default().push(index); + } + } + let group_of: Vec> = features.iter().map(|feature| member_of.get(&feature.0).copied()).collect(); + // `doc.sketch(id)` reads the first feature with the id. + let has_patterns = features + .iter() + .map(|feature| { + index_of.get(&feature.0).and_then(|index| doc.features.get(*index)).is_some_and( + |feature| matches!(&feature.kind, solidcraft_engine::doc::FeatureKind::Sketch { sketch, .. } if !sketch.patterns.is_empty()), + ) + }) + .collect(); + // A parent that is no folder (any more) is still looked up by its key, as before. + let fold_ids: BTreeSet = groups.iter().flat_map(|group| [Some(group.id), group.parent]).flatten().collect(); + let mut plan = Self { + key: PlanKey::of(app, under_feature), + index_of, + group_of, + has_patterns, + by_id, + depth: Vec::new(), + first: Vec::new(), + position: Vec::new(), + end: Vec::new(), + starting: vec![Vec::new(); features.len()], + ending: vec![Vec::new(); features.len()], + trailing: Vec::new(), + contents: Vec::new(), + joints: Vec::new(), + fold_keys: fold_ids.into_iter().map(|id| (id, format!("timeline_group:{id}"))).collect(), + host_keys: rows.rows.iter().filter(|row| !row.listed.is_empty()).map(|row| (row.feature, sketches_key(row.feature))).collect(), + features, + groups, + rows, + }; + plan.place_folders(&children); + plan + } + + /// The folders' depths, extents, header places and contents. + fn place_folders(&mut self, children: &HashMap>) { + let groups = &self.groups; + let n = self.features.len(); + let ids: Vec = groups.iter().map(|group| group.id).collect(); + self.depth = ids.iter().map(|id| self.depth_of(*id)).collect(); + let mut first = HashMap::new(); + let mut last = HashMap::new(); + for id in &ids { + self.extent(*id, children, &mut first, &mut last, &mut HashSet::new()); + } + self.first = ids.iter().map(|id| first.get(id).copied().flatten()).collect(); + self.end = ids.iter().map(|id| last.get(id).copied().flatten().map_or(n, |index: usize| index.saturating_add(1))).collect(); + // A folder with only joints shares its nearest feature-bearing ancestor's position. + self.position = ids + .iter() + .map(|id| { + let mut current = Some(*id); + for _ in 0..groups.len() { + let id = current?; + if let Some(first) = first.get(&id).copied().flatten() { + return Some(first); + } + current = self.parent_of(id); + } + None + }) + .collect(); + let paths: Vec> = ids.iter().map(|id| group_path(groups, *id)).collect(); + for (index, position) in self.position.iter().enumerate() { + if let Some(slot) = position.and_then(|position| self.starting.get_mut(position)) { + slot.push(index); + } + } + for starting in &mut self.starting { + starting.sort_by(|a, b| paths.get(*a).cmp(&paths.get(*b))); + } + for (index, id) in ids.iter().enumerate() { + if let Some(slot) = last.get(id).copied().flatten().and_then(|last| self.ending.get_mut(last)) { + slot.push(index); + } + } + for ending in &mut self.ending { + ending.sort_by_key(|index| std::cmp::Reverse(self.depth.get(*index).copied())); + } + let mut trailing: Vec = (0..groups.len()).collect(); + let place = |at: usize| (self.position.get(at).copied().flatten().unwrap_or(usize::MAX), paths.get(at)); + trailing.sort_by(|a, b| place(*a).cmp(&place(*b))); + self.trailing = trailing; + // A folder holds its own features and those of the folders nested in it, as + // `group_is_descendant` decides (a broken chain of parents counts as inside everything). + let ancestors: HashMap>> = ids.iter().map(|id| (*id, self.ancestors(*id))).collect(); + let inside = |group: u64, root: u64| group == root || ancestors.get(&group).is_some_and(|up| up.as_ref().is_none_or(|up| up.contains(&root))); + self.contents = ids + .iter() + .map(|root| { + self.group_of + .iter() + .zip(&self.features) + .filter(|(group, _)| group.is_some_and(|group| inside(group, *root))) + .map(|(_, feature)| feature.0) + .collect() + }) + .collect(); + self.joints = ids + .iter() + .map(|root| groups.iter().filter(|candidate| inside(candidate.id, *root)).flat_map(|group| group.joints.iter().copied()).collect()) + .collect(); + } + + /// The first and last timeline positions of `id`'s own and nested features. + fn extent( + &self, + id: u64, + children: &HashMap>, + first: &mut HashMap>, + last: &mut HashMap>, + visiting: &mut HashSet, + ) -> (Option, Option) { + if let (Some(a), Some(b)) = (first.get(&id), last.get(&id)) { + return (*a, *b); + } + // A folder nested in itself adds nothing more. + if !visiting.insert(id) { + return (None, None); + } + let Some(group) = self.by_id.get(&id).and_then(|index| self.groups.get(*index)) else { + first.insert(id, None); + last.insert(id, None); + return (None, None); + }; + let direct: Vec = group.features.iter().filter_map(|feature| self.index_of.get(feature).copied()).collect(); + let (mut low, mut high) = (direct.iter().copied().min(), direct.iter().copied().max()); + for child in children.get(&id).into_iter().flatten() { + let Some(child) = self.groups.get(*child).map(|child| child.id) else { continue }; + let (a, b) = self.extent(child, children, first, last, visiting); + low = low.into_iter().chain(a).min(); + high = high.into_iter().chain(b).max(); + } + visiting.remove(&id); + first.insert(id, low); + last.insert(id, high); + (low, high) + } + + fn parent_of(&self, id: u64) -> Option { + self.by_id.get(&id).and_then(|index| self.groups.get(*index)).and_then(|group| group.parent) + } + + /// The depth of the first folder with `id` (`group_depth`). + pub(super) fn depth_of(&self, id: u64) -> usize { + let mut depth = 0; + let mut current = self.parent_of(id); + while let Some(parent) = current { + depth += 1; + if depth > self.groups.len() { + break; + } + current = self.parent_of(parent); + } + depth + } + + /// The folders above `id`; `None` when its chain of parents never ends. + fn ancestors(&self, id: u64) -> Option> { + let mut found = HashSet::new(); + let mut current = self.parent_of(id); + let mut steps = 0usize; + while let Some(parent) = current { + found.insert(parent); + steps += 1; + if steps > self.groups.len() { + return None; + } + current = self.parent_of(parent); + } + Some(found) + } + + /// Whether `id` and every folder around it are open (`group_is_open`). + pub(super) fn is_open(&self, folds: &Folds, id: u64) -> bool { + !folds.is_closed(id) && self.ancestors_open(folds, id) + } + + /// Whether every folder around `id` is open (`group_ancestors_are_open`). + pub(super) fn ancestors_open(&self, folds: &Folds, id: u64) -> bool { + let mut current = self.parent_of(id); + let mut steps = 0usize; + while let Some(group) = current { + if folds.is_closed(group) { + return false; + } + steps += 1; + if steps > self.groups.len() { + return false; + } + current = self.parent_of(group); + } + true + } + + /// Whether the feature with `id` is suppressed. + pub(super) fn suppressed(&self, id: u64) -> bool { + self.index_of.get(&id).and_then(|index| self.features.get(*index)).is_some_and(|feature| feature.3) + } + + /// The name of the feature with `id`. + pub(super) fn name(&self, id: u64) -> Option<&str> { + self.index_of.get(&id).and_then(|index| self.features.get(*index)).map(|feature| feature.1.as_str()) + } + + /// Which folders and folds are open now. + pub(super) fn folds(&self, app: &SolveApp) -> Folds { + let closed = self.fold_keys.iter().filter(|(_, key)| app.tree.collapsed.contains(key)).map(|(id, _)| *id).collect(); + let open_hosts = self.host_keys.iter().filter(|(_, key)| crate::browser::is_open(app, key, false)).map(|(feature, _)| *feature).collect(); + Folds { closed, open_hosts } + } + + /// The visible items top to bottom (`visible_order`), kept until a folder or fold opens or + /// closes. + pub(super) fn order(&self, ui: &egui::Ui, folds: &Folds) -> Arc> { + type Kept = (PlanKey, Folds, Arc>); + let id = egui::Id::new("sc_history_row_order"); + if let Some(kept) = ui.data(|data| data.get_temp::>(id)).filter(|kept| kept.0 == self.key && kept.1 == *folds) { + return kept.2.clone(); + } + let order = Arc::new(self.visible_order(folds)); + ui.data_mut(|data| data.insert_temp(id, Arc::new((self.key, folds.clone(), order.clone())))); + order + } + + pub(super) fn visible_order(&self, folds: &Folds) -> Vec { + let mut order = Vec::new(); + let mut shown = HashSet::new(); + for (index, (id, ..)) in self.features.iter().enumerate() { + for group in self.starting.get(index).into_iter().flatten().filter_map(|group| self.groups.get(*group)) { + if self.ancestors_open(folds, group.id) && shown.insert(group.id) { + order.push(TimelineItem::Group(group.id)); + } + } + // A listed sketch follows the feature built from it. + let Some(row) = self.rows.row_at(index) else { continue }; + if self.group_of.get(index).copied().flatten().is_none_or(|group| self.is_open(folds, group)) { + order.push(TimelineItem::Feature(*id)); + if !row.listed.is_empty() && folds.open_hosts.contains(id) { + order.extend(row.listed.iter().filter(|listed| !listed.reference).map(|listed| TimelineItem::Feature(listed.sketch))); + } + } + } + for group in self.trailing.iter().filter_map(|group| self.groups.get(*group)) { + if !shown.contains(&group.id) && self.ancestors_open(folds, group.id) && shown.insert(group.id) { + order.push(TimelineItem::Group(group.id)); + } + } + order + } +} + +#[cfg(test)] +impl RowPlan { + /// Everything the plan holds, comparable: a cached plan must equal one built afresh. + pub(super) fn summary(&self) -> String { + let features: Vec<_> = self.features.iter().map(|f| (f.0, &f.1, f.2, f.3, f.4, std::mem::discriminant(&f.5))).collect(); + let mut index_of: Vec<_> = self.index_of.iter().collect(); + index_of.sort(); + let mut by_id: Vec<_> = self.by_id.iter().collect(); + by_id.sort(); + format!( + "{features:?}\n{:?}\n{:?}\n{index_of:?}\n{:?}\n{:?}\n{by_id:?}\n{:?}\n{:?}\n{:?}\n{:?}\n{:?}\n{:?}\n{:?}\n{:?}\n{:?}\n{:?}\n{:?}", + self.groups, + self.rows, + self.group_of, + self.has_patterns, + self.depth, + self.first, + self.position, + self.end, + self.starting, + self.ending, + self.trailing, + self.contents, + self.joints, + self.fold_keys, + self.host_keys, + ) + } + + pub(super) fn under_feature(&self) -> bool { + self.key.under_feature + } + + /// The plan the History drew with last, from the egui context. + pub(super) fn kept(ctx: &egui::Context) -> Option> { + ctx.data(|data| data.get_temp::>(egui::Id::new("sc_history_row_plan"))) + } +} diff --git a/crates/ui-egui/src/tool_cursor.rs b/crates/ui-egui/src/tool_cursor.rs index c0975fe61a..aeffb03551 100644 --- a/crates/ui-egui/src/tool_cursor.rs +++ b/crates/ui-egui/src/tool_cursor.rs @@ -95,10 +95,10 @@ mod tests { } #[test] - fn navigation_preference_restores_tool_badge_on_cancel_and_defaults() { + fn tools_preference_restores_tool_badge_on_cancel_and_defaults() { let mut h = Harness::new(); h.app.home.open = false; - h.call("ui.prefs", json!({"open":"Navigation"})); + h.call("ui.prefs", json!({"open":"Tools"})); h.frames(4); crate::prefs::reveal_preference(&mut h, "view:tool_cursor"); h.step(&json!({"click":{"handle":"view:tool_cursor"}})).unwrap(); @@ -106,32 +106,94 @@ mod tests { h.call("ui.prefs", json!({"button":"cancel"})); assert!(!h.app.ui.tool_cursor); h.app.ui.tool_cursor = true; - h.call("ui.prefs", json!({"open":"Navigation"})); + h.call("ui.prefs", json!({"open":"Tools"})); h.call("ui.prefs", json!({"button":"defaults"})); assert!(!h.app.ui.tool_cursor); h.call("ui.prefs", json!({"button":"cancel"})); assert!(h.app.ui.tool_cursor, "Cancel also restores the value from before Defaults"); } + #[test] + fn tools_preference_keeps_applied_tool_badge_after_restart() { + let mut h = Harness::new(); + h.app.home.open = false; + h.call("ui.prefs", json!({"open":"Tools"})); + h.frames(4); + crate::prefs::reveal_preference(&mut h, "view:tool_cursor"); + h.step(&json!({"click":{"handle":"view:tool_cursor"}})).unwrap(); + assert!(h.app.ui.tool_cursor); + h.call("ui.prefs", json!({"button":"ok"})); + + let mut restarted = crate::SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); + restarted.load_prefs(&h.app.prefs()); + assert!(restarted.ui.tool_cursor, "an explicitly enabled tool badge survives a restart"); + + // Turning it off must replace a previously saved true value too. + h.call("ui.prefs", json!({"open":"Tools"})); + h.frames(4); + crate::prefs::reveal_preference(&mut h, "view:tool_cursor"); + h.step(&json!({"click":{"handle":"view:tool_cursor"}})).unwrap(); + assert!(!h.app.ui.tool_cursor); + h.call("ui.prefs", json!({"button":"apply"})); + restarted.load_prefs(&h.app.prefs()); + assert!(!restarted.ui.tool_cursor, "Apply also saves turning the tool badge off"); + } + + #[test] + fn tools_preference_does_not_save_unapplied_tool_badge_changes() { + let mut h = Harness::new(); + h.app.home.open = false; + let saved = |h: &Harness| serde_json::from_str::(&h.app.prefs()).unwrap()["tool_cursor"].clone(); + h.call("ui.prefs", json!({"open":"Tools"})); + h.frames(4); + crate::prefs::reveal_preference(&mut h, "view:tool_cursor"); + h.step(&json!({"click":{"handle":"view:tool_cursor"}})).unwrap(); + assert!(h.app.ui.tool_cursor); + assert_eq!(saved(&h), false, "periodic saves and quit keep the last applied value"); + h.call("ui.prefs", json!({"button":"apply"})); + assert_eq!(saved(&h), true); + h.call("ui.prefs", json!({"button":"defaults"})); + assert!(!h.app.ui.tool_cursor); + assert_eq!(saved(&h), true, "Restore Defaults also waits for Apply or OK"); + h.call("ui.prefs", json!({"button":"cancel"})); + assert!(h.app.ui.tool_cursor); + assert_eq!(saved(&h), true, "Cancel preserves the applied choice in the settings file"); + } + + #[test] + fn saved_tool_badge_requires_a_boolean_and_old_settings_keep_the_default() { + let mut app = crate::SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); + app.load_prefs("{}"); + assert!(!app.ui.tool_cursor, "settings from before the option was saved keep it off"); + for enabled in [true, false] { + app.load_prefs(&json!({"tool_cursor":enabled}).to_string()); + assert_eq!(app.ui.tool_cursor, enabled); + for invalid in [json!({}), json!({"tool_cursor":null}), json!({"tool_cursor":"true"}), json!({"tool_cursor":1})] { + app.load_prefs(&invalid.to_string()); + assert_eq!(app.ui.tool_cursor, enabled, "missing or malformed flags leave the current setting alone"); + } + } + } + #[test] fn badge_shows_the_running_tool_beside_the_pointer() { let mut h = Harness::new(); h.settle(); - // Off by default: a running tool shows no badge until Navigation preferences turn it on. + // Off by default: a running tool shows no badge until Tools preferences turn it on. assert!(!h.app.ui.tool_cursor, "the badge is on by default"); h.step(&json!({"start": "sketch.create"})).unwrap(); let (_, badge) = hover_middle(&mut h); assert!(badge.is_none(), "off by default, yet a badge is drawn at {badge:?}"); h.step(&json!({"key": "Escape"})).unwrap(); h.settle(); - // The board's Display menu omits this preference; Navigation keeps its checkbox. - h.call("ui.prefs", json!({"open":"Navigation"})); + // The board's Display menu omits this preference; Tools keeps its checkbox. + h.call("ui.prefs", json!({"open":"Tools"})); h.frames(4); crate::prefs::reveal_preference(&mut h, "view:tool_cursor"); h.step(&json!({"click": {"handle": "view:tool_cursor"}})).unwrap(); h.call("ui.prefs", json!({"button":"ok"})); h.frames(2); - assert!(h.app.ui.tool_cursor, "the Navigation preferences checkbox did not turn the badge on"); + assert!(h.app.ui.tool_cursor, "the Tools preferences checkbox did not turn the badge on"); let (_, badge) = hover_middle(&mut h); assert!(badge.is_none(), "no tool runs, yet a badge is drawn at {badge:?}"); @@ -152,7 +214,7 @@ mod tests { // The coordinate readout starts READOUT below and right of the pointer: no overlap. assert!(badge.bottom() < at.y + READOUT.y, "the badge {badge:?} covers the readout under {at:?}"); - // Turned off in Navigation preferences. + // Turned off in Tools preferences. h.app.ui.tool_cursor = false; let (_, badge) = hover_middle(&mut h); assert!(badge.is_none(), "turned off, yet a badge is drawn at {badge:?}"); diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 4ad86e43c7..6d6d35c9d5 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -1283,6 +1283,9 @@ fn ribbon_tabs(app: &mut SolveApp, ui: &mut egui::Ui, row: Rect) { } if response.clicked() { app.ui.tab = tab.to_string(); + // Floating ribbons draw after the viewport. An instant surface switch + // needs one more pass too; settled tabs never schedule their own frames. + ui.ctx().request_repaint(); } if response.secondary_clicked() { crate::context_menu::open_for( @@ -1713,7 +1716,9 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) { resp.request_focus(); } let id = selected.id; - app.start_pinned(id, Some(whole)); + if !end_running_tool(app, id) { + app.start_pinned(id, Some(whole)); + } } if persistent { let label = persistent_button_name(app, selected); @@ -1828,7 +1833,7 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) { if resp.secondary_clicked() { menu = Some((resp.interact_pointer_pos().unwrap_or(cell.left_bottom()), c.id)); } - if ribbon_tooltip(app, resp, c, info.disabled_reason.as_deref()).clicked() && info.enabled { + if ribbon_tooltip(app, resp, c, info.disabled_reason.as_deref()).clicked() && info.enabled && !end_running_tool(app, c.id) { app.start_at(c.id, Some(cell)); } } @@ -2908,11 +2913,54 @@ pub(crate) fn selection_filter(app: &mut SolveApp, ui: &mut egui::Ui) { } } } + // The selection shape (#4376): a drag draws a freehand lasso instead of the rectangle. + let label = app.tr("ui.lasso_select").to_string(); + ui_kit::checkbox(ui, &label, &mut app.ui.lasso_select); +} + +/// A click on the lit button of the sketch tool that is running ends the tool, as a toggle does, +/// so clicks in the view select again (#10151). With Preferences › Sketch "Clicking the running +/// tool's button ends it" off, the click starts the tool again, as it did before. Returns whether +/// the tool was ended. A persistent analysis button already turns its analysis off its own way. +fn end_running_tool(app: &mut SolveApp, id: &str) -> bool { + if !app.preferences.ribbon_click_ends_tool || persistent_available(app, id) || !app.tool.as_ref().is_some_and(|t| t.cmd == id) { + return false; + } + crate::dim_view::cancel(); + app.tool = None; + true } /// Two Esc presses within this many seconds finish the open sketch. pub const ESCAPE_TWICE_S: f64 = 0.4; +/// Whether `cancel_unfinished` has something to cancel. +pub(crate) fn unfinished(app: &SolveApp) -> bool { + app.tool.is_some() + || crate::select_other::is_open(app) + || app.menu.joint_move_warning.is_some() + || app.viewport.joint_handles.is_some() + || app.viewport.text_drag.is_some() + || crate::occurrence_drag::active(app) +} + +/// Cancels what one Esc cancels before it reaches the dialog, the selection or the sketch: +/// a drag, the Select Other list, or the active tool's points and then the tool. The view's +/// corner Cancel (#6709) takes this step first too. Returns whether anything was cancelled. +pub(crate) fn cancel_unfinished(app: &mut SolveApp) -> bool { + if crate::joint_handles::cancel(app) || crate::sketch_text_move::cancel_drag(app) || crate::occurrence_drag::cancel(app) { + return true; + } + if crate::select_other::is_open(app) { + crate::select_other::close(app); + } else if app.tool.is_some() { + crate::tools::finish(app); + } else { + return false; + } + true +} + /// Global keyboard shortcuts (when no text field has focus). pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) { crate::viewport_status::reconcile(app, ctx); @@ -2977,6 +3025,20 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) { || crate::hole_presets::name_prompt_open(ctx, app.dialog.as_ref()) || crate::personal_sheet_rules::name_prompt_open(ctx, app.dialog.as_ref()); let inline_editor = crate::dim_view::editing(); + if !popup_owner + && app.viewport_status.open_key.is_some() + && crate::ui_kit::details_field_owned_escape(ctx) + && ctx.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) + { + ctx.memory_mut(|memory| { + if let Some(id) = memory.focused() { + memory.surrender_focus(id); + } + }); + app.esc_handled = true; + app.escape_owner = Some(crate::EscapeOwner::Overlay); + return; + } if popup_owner || inline_editor || (crate::viewport_status::enabled(app) && app.ui.palette_open) @@ -3089,19 +3151,15 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) { let blank = app.session.doc.sketch(sketch).is_ok_and(crate::sketch_palette::is_blank); let depth = app.session.undo.len(); app.finish_sketch(); - if blank && app.session.active_sketch.is_none() && app.run("timeline.delete", json!({ "feature": sketch.to_string() })).is_ok() { + if blank && app.session.active_sketch.is_none() && app.run("timeline.delete", json!({ "feature": sketch })).is_ok() { app.session.undo.truncate(depth + 1); } continue; } - if crate::joint_handles::cancel(app) || crate::sketch_text_move::cancel_drag(app) || crate::occurrence_drag::cancel(app) { + if cancel_unfinished(app) { continue; } - if crate::select_other::is_open(app) { - crate::select_other::close(app); - } else if app.tool.is_some() { - crate::tools::finish(app); - } else if app.dialog.is_some() || app.pending_feature_edit.is_some() { + if app.dialog.is_some() || app.pending_feature_edit.is_some() { crate::dialogs::cancel(app); } else if crate::viewport_status::enabled(app) && crate::viewport_status::escape(app, repeat) { app.last_escape = None; @@ -4534,6 +4592,45 @@ mod dropdown_toggle_tests { assert!(rect_of("toolbar_dropdown").is_none(), "the second click closes it"); } + /// A click on the lit button of the sketch tool in use ends it, so clicks in the view select + /// again and Ctrl+click adds lines to the selection (#10151); a constraint in the ribbon's + /// grid ends the same way. With the preference off, the click starts the tool again. + #[test] + fn a_second_click_on_a_running_tools_button_ends_it() { + use solidcraft_engine::Sel; + let mut h = Harness::new(); + h.app.home.open = false; + h.app.set_origin_shown(false); + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + h.app.run("sketch.rectangle.two_point", json!({"p0":[-20,-20],"p1":[20,20]})).unwrap(); + h.app.run("sketch.rectangle.two_point", json!({"p0":[-10,-10],"p1":[10,10]})).unwrap(); + h.app.cam.distance = 100.0; + h.settle(); + let grid = format!("ribbon:constraint:{}", crate::workspace::CONSTRAINT_GALLERY[0]); + for handle in ["button:sketch.line", "button:sketch.dimension", grid.as_str()] { + h.step(&json!({"click": {"handle": handle}})).unwrap(); + let running = h.app.tool.as_ref().map(|t| t.cmd); + assert!(running.is_some(), "{handle}: the first click starts the tool"); + h.frames(30); + h.step(&json!({"click": {"handle": handle}})).unwrap(); + assert!(h.app.tool.is_none(), "{handle}: the second click ends {running:?}"); + h.frames(30); + } + // With no tool running, clicks select, and Ctrl+click adds lines of both rectangles. + for (i, p) in [[-15.0, 20.0], [20.0, 15.0], [-5.0, 10.0], [10.0, 5.0]].iter().enumerate() { + let at = h.call("ui.at", json!({"sketch": p}))["result"].clone(); + h.step(&json!({"click": at, "ctrl": i > 0})).unwrap(); + } + let curves = h.app.session.selection.iter().filter(|s| matches!(s, Sel::SketchCurve { .. })).count(); + assert_eq!(curves, 4, "{:?}", h.app.session.selection); + // The old way, behind the preference: the click starts the tool again. + h.app.preferences.ribbon_click_ends_tool = false; + h.step(&json!({"click": {"handle": "button:sketch.line"}})).unwrap(); + h.frames(30); + h.step(&json!({"click": {"handle": "button:sketch.line"}})).unwrap(); + assert_eq!(h.app.tool.as_ref().map(|t| t.cmd), Some("sketch.line")); + } + /// A drop-down row's help tooltip names the key the command runs from now, not its default. #[test] fn a_drop_down_row_tooltip_names_a_rebound_key() { diff --git a/crates/ui-egui/src/tools.rs b/crates/ui-egui/src/tools.rs index 26c698eae9..74d0378819 100644 --- a/crates/ui-egui/src/tools.rs +++ b/crates/ui-egui/src/tools.rs @@ -97,6 +97,9 @@ pub struct Tool { /// Where each point was placed and the sketch points the snap lined it up with (#7828): /// constrained to them once the shape is made. pub lined: Vec<(Vec2, Vec)>, + /// The Line tool's chain, which a rest on its last point switches to tangent arcs and back + /// (#9489). + pub chain: crate::sketch_line_arc::LineChain, } impl Tool { @@ -149,6 +152,7 @@ impl Tool { dims_focus: false, arc_turn: 0.0, lined: Vec::new(), + chain: Default::default(), }) } } @@ -518,6 +522,10 @@ pub fn place(app: &mut SolveApp, p: (Vec2, Option)) { tool.pts.push(p); let before = crate::sketch_tools::point_ids(app); if tool.cmd == "sketch.line" { + // A new chain's first point: nothing drawn yet to go on tangent from. + if tool.pts.len() == 1 { + tool.chain = Default::default(); + } if tool.pts.len() >= 2 { let (a, b) = (tool.pts[tool.pts.len() - 2].clone(), tool.pts[tool.pts.len() - 1].clone()); let angled = crate::sketch_dims::has_locked(&tool, "Angle"); @@ -543,6 +551,7 @@ pub fn place(app: &mut SolveApp, p: (Vec2, Option)) { tool.pts[last].1 = Some(format!("{id}.end")); // The chain needs only its current endpoint, even after many segments. tool.pts = vec![tool.pts[last].clone()]; + tool.chain.placed(id); } crate::sketch_dims::reset(&mut tool); } else { @@ -626,7 +635,9 @@ fn run_shape(app: &mut SolveApp, t: &Tool) -> Result, String> { v } "sketch.circle.two_point" => json!({"p0": xy(&p[0]), "p1": xy(&p[1])}), - "sketch.circle.three_point" => json!({"p0": xy(&p[0]), "p1": xy(&p[1]), "p2": xy(&p[2])}), + // Each click goes as its snap, as the 3-Point Arc's do: a click snapped to a point or + // curve keeps the circle on it (#9858). + "sketch.circle.three_point" => json!({"p0": arg(&p[0]), "p1": arg(&p[1]), "p2": arg(&p[2])}), "sketch.arc.three_point" => json!({"start": arg(&p[0]), "end": arg(&p[1]), "through": arg(&p[2])}), "sketch.arc.center_point" => { // A centre snapped to an existing point goes as its place, where `connect` ties @@ -868,6 +879,9 @@ pub fn finish(app: &mut SolveApp) { if crate::sketch3d::finish(app) { return; } + if crate::sketch_line_arc::finish(app) { + return; + } if let Some(mut t) = app.tool.take_if(|t| t.kind == Kind::Ext) { if crate::sketch_tools::finish(app, &mut t) { app.tool = Some(t); diff --git a/crates/ui-egui/src/ui_kit/actions.rs b/crates/ui-egui/src/ui_kit/actions.rs index 898d94c1f2..e0c9ea418c 100644 --- a/crates/ui-egui/src/ui_kit/actions.rs +++ b/crates/ui-egui/src/ui_kit/actions.rs @@ -24,6 +24,98 @@ fn pointer_on_hover(ui: &Ui, response: &Response) { } } +#[derive(Clone, Copy)] +enum ButtonTone { + Normal, + Primary, + Danger, + Selected, + Ghost, +} + +struct ButtonAppearance { + fill: egui::Color32, + ink: egui::Color32, + stroke: egui::Stroke, +} + +/// Use the theme's hover/selection surfaces for secondary actions. Accent actions retain their +/// colour, with shades mixed from theme tokens rather than a separate hard-coded palette. +fn button_appearance(ui: &Ui, response: Option<&Response>, tone: ButtonTone) -> ButtonAppearance { + let t = Tokens::get(); + let enabled = ui.is_enabled(); + let hovered = enabled && response.is_some_and(Response::hovered); + let focused = enabled && response.is_some_and(Response::has_focus); + let pressed = enabled + && response.is_some_and(|r| { + (r.is_pointer_button_down_on() && r.contains_pointer()) + || (r.has_focus() && ui.input(|i| i.key_down(egui::Key::Space) || i.key_down(egui::Key::Enter))) + }); + let bevel = t.borders.bevel.is_some(); + let accented = matches!(tone, ButtonTone::Danger) || (!bevel && matches!(tone, ButtonTone::Primary | ButtonTone::Selected)); + let (mut fill, mut ink) = match tone { + ButtonTone::Primary | ButtonTone::Selected if bevel => (t.control_fill, t.form_ink), + ButtonTone::Primary => (t.form_primary_fill, t.on_accent), + ButtonTone::Danger => (t.form_error, t.on_accent), + ButtonTone::Selected => selected_button_palette(&t), + ButtonTone::Normal | ButtonTone::Ghost => (t.control_fill, t.form_ink), + }; + if enabled { + if accented { + let amount = if pressed { + 0.24 + } else if hovered { + 0.12 + } else { + 0.0 + }; + fill = fill.lerp_to_gamma(t.form_ink, amount); + } else if pressed { + (fill, ink) = (t.selected_bg, t.selected_ink); + // Some built-in themes share their hover and selection colours. + if fill == t.hover || fill == t.control_fill { + fill = fill.lerp_to_gamma(t.form_ink, 0.24); + } + } else if hovered { + fill = if t.hover == t.control_fill { t.hover.lerp_to_gamma(t.form_ink, 0.12) } else { t.hover }; + } + if matches!(tone, ButtonTone::Ghost) && (hovered || pressed || focused) { + ink = if pressed { t.form_primary.lerp_to_gamma(t.form_ink, 0.24) } else { t.form_primary }; + } else if crate::theme::contrast_ratio(ink, fill) < 4.5 { + // Custom themes can use a light accent with light on-accent text. Select a readable + // foreground from that same theme for each interaction shade. + ink = [t.form_ink, t.on_accent, t.tab_on_fill_dark].into_iter().fold(ink, |best, candidate| { + if crate::theme::contrast_ratio(candidate, fill) > crate::theme::contrast_ratio(best, fill) { candidate } else { best } + }); + } + } + if !enabled { + ink = t.form_dim; + } + let stroke = if focused || hovered || pressed || matches!(tone, ButtonTone::Selected) { + t.control_border_stroke(t.form_primary) + } else if matches!(tone, ButtonTone::Primary | ButtonTone::Danger) { + t.control_border_stroke(fill) + } else { + t.control_border_stroke(t.control_border) + }; + ButtonAppearance { fill, ink, stroke } +} + +fn styled_button(ui: &Ui, label: egui::WidgetText, tone: ButtonTone) -> egui::Button<'static> { + // Match egui's own Button lookup: its previous rectangle is hit-tested with this frame's + // input before allocation. No extra UI scope or widget changes the button's identity. + let response = ui.ctx().read_response(ui.next_auto_id()); + let mut appearance = button_appearance(ui, response.as_ref(), tone); + if Tokens::get().borders.bevel.is_none() && !matches!(tone, ButtonTone::Selected) { + // Native button padding already accounts for this state's border. Preserve its width + // when changing the colour, or buttons outside a form grow and make toast actions wrap. + let state = response.as_ref().map(Response::widget_state).unwrap_or_default(); + appearance.stroke.width = ui.style().button_style(&Default::default(), state).frame.stroke.width; + } + egui::Button::new(label.color(appearance.ink)).fill(appearance.fill).stroke(appearance.stroke) +} + /// Icon action inside an existing rectangle. Tokens: `form_icon_inset, text, text_dim; icon palette`. pub(crate) fn icon_button_at(ui: &mut Ui, rect: egui::Rect, icon: &str, label: &str) -> Response { icon_button_colored(ui, rect, icon, label, None) @@ -182,17 +274,25 @@ pub(super) fn footer_with_left( ui.scope_builder(egui::UiBuilder::new().max_rect(left_rect).layout(egui::Layout::left_to_right(egui::Align::Center)), |ui| { if let Some(label) = apply_label.filter(|_| confirm) { let marked = selected == Some(Action::Apply); - let mut button = - egui::Button::new(crate::i18n::tr_label(label)).frame(secondary_left).selected(marked).min_size(vec2(0.0, t.form_button_height)); - if secondary_left { - ui.spacing_mut().button_padding = vec2(t.space_12, 0.0); - button = button - .fill(if marked { t.selected_bg } else { t.control_fill }) - .stroke(egui::Stroke::new(1.0, if marked { t.form_primary } else { t.control_border })) - .corner_radius(t.control_radius); - } + let tone = if !secondary_left { + ButtonTone::Ghost + } else if marked { + ButtonTone::Selected + } else { + ButtonTone::Normal + }; // A confirmation's third choice (Don't Save, Keep) never waited for OK's inputs. - let response = ui.add_enabled(!nothing_to_apply, button); + let response = ui.add_enabled(!nothing_to_apply, |ui: &mut Ui| { + let mut button = styled_button(ui, crate::i18n::tr_label(label).into(), tone) + .frame(secondary_left) + .selected(marked) + .min_size(vec2(0.0, t.form_button_height)); + if secondary_left { + ui.spacing_mut().button_padding = vec2(t.space_12, 0.0); + button = button.corner_radius(t.control_radius); + } + ui.add(button) + }); pointer_on_hover(ui, &response); scenario::publish_target("dialog:apply", response.rect); scenario::publish_handle(&format!("dialog:{label}"), response.rect.center()); @@ -217,28 +317,12 @@ struct ActionLook { ok_enabled: bool, apply_enabled: bool, selected: Option, - primary_fill: egui::Color32, - primary_ink: egui::Color32, + danger: bool, } impl ActionLook { fn new(ok_enabled: bool, apply_enabled: bool, selected: Option, danger: bool) -> Self { - let t = Tokens::get(); - let primary_fill = if t.borders.bevel.is_some() && !danger { - t.control_fill - } else if danger { - t.form_error - } else { - t.form_primary_fill - }; - let primary_ink = if t.borders.bevel.is_some() && !danger { - t.form_ink - } else if danger && crate::theme::contrast_ratio(t.on_accent, primary_fill) < 4.5 { - t.tab_on_fill_dark - } else { - t.on_accent - }; - Self { ok_enabled, apply_enabled, selected, primary_fill, primary_ink } + Self { ok_enabled, apply_enabled, selected, danger } } /// Cancel always works; OK and Apply need valid inputs, and Apply also something to apply. @@ -265,35 +349,24 @@ fn action_button(ui: &mut Ui, label: &str, kind: Action, look: &ActionLook, padd let primary = look.primary(kind); let live = look.live(kind); ui.spacing_mut().button_padding = vec2(padding, 0.0); - let text = RichText::new(crate::i18n::tr_label(label)).size(t.font_label).color(if primary && ok_enabled { - look.primary_ink - } else if primary || !live { - t.form_dim + let text = RichText::new(crate::i18n::tr_label(label)).size(t.font_label); + let tone = if primary && ok_enabled { + if look.danger { ButtonTone::Danger } else { ButtonTone::Primary } + } else if marked { + ButtonTone::Selected } else { - t.form_ink + ButtonTone::Normal + }; + // Style after add_enabled sets the state, preserving the native button's ID and layout. + let response = ui.add_enabled(live, |ui: &mut Ui| { + ui.add( + styled_button(ui, if primary && ok_enabled { text.bold_face().into() } else { text.into() }, tone) + .selected(marked && !primary) + .wrap_mode(egui::TextWrapMode::Extend) + .corner_radius(t.control_radius) + .min_size(vec2(min_width, t.form_button_height)), + ) }); - let response = ui.add_enabled( - live, - egui::Button::new(if primary && ok_enabled { text.bold_face() } else { text }) - .selected(marked && !primary) - .fill(if primary && ok_enabled { - look.primary_fill - } else if marked { - t.selected_bg - } else { - t.control_fill - }) - .stroke(if primary && ok_enabled { - egui::Stroke::NONE - } else if marked && !primary { - egui::Stroke::new(1.0, t.form_primary) - } else { - t.control_border_stroke(t.control_border) - }) - .wrap_mode(egui::TextWrapMode::Extend) - .corner_radius(t.control_radius) - .min_size(vec2(min_width, t.form_button_height)), - ); t.button_bevel(ui, &response, false); pointer_on_hover(ui, &response); let key = match kind { @@ -474,24 +547,16 @@ pub(crate) fn native_button_focus(ui: &Ui, response: &Response, rect: egui::Rect egui::Shape::rect_stroke(rect, Tokens::get().radius_sm, visuals.bg_stroke, egui::StrokeKind::Inside) } -fn button_widget(label: egui::WidgetText, options: Button) -> egui::Button<'static> { - let t = Tokens::get(); - if t.borders.bevel.is_some() && !matches!(options, Button::Ghost) { - let button = egui::Button::new(label.color(t.form_ink)).fill(t.control_fill).stroke(t.control_border_stroke(t.control_border)); - return if matches!(options, Button::Small | Button::SmallSelected) { button.small() } else { button }; - } - match options { - Button::Small => egui::Button::new(label).small(), - Button::Primary => { - let ink = if crate::theme::contrast_ratio(t.on_accent, t.form_primary_fill) >= 4.5 { t.on_accent } else { t.tab_on_fill_dark }; - egui::Button::new(label.color(ink)).fill(t.form_primary_fill) - } - Button::Ghost => egui::Button::new(label).frame(false), - Button::SmallSelected => { - let (fill, ink) = selected_button_palette(&t); - egui::Button::new(label.color(ink)).small().fill(fill).stroke(egui::Stroke::new(1.0, t.form_primary)) - } - _ => egui::Button::new(label), +fn button_widget(label: egui::WidgetText, options: Button) -> impl egui::Widget { + move |ui: &mut Ui| { + let widget = match options { + Button::Small => styled_button(ui, label, ButtonTone::Normal).small(), + Button::Primary => styled_button(ui, label, ButtonTone::Primary), + Button::Ghost => styled_button(ui, label, ButtonTone::Ghost).frame(false), + Button::SmallSelected => styled_button(ui, label, ButtonTone::Selected).small(), + _ => styled_button(ui, label, ButtonTone::Normal), + }; + ui.add(widget) } } @@ -552,15 +617,11 @@ pub(crate) fn primary_button(ui: &mut Ui, label: &str) -> Response { /// `form_primary, on_accent, form_button_height, font_label, control_radius, space_6/10`. pub(crate) fn wide_primary_button(ui: &mut Ui, label: &str) -> Response { let t = Tokens::get(); - let bevel = t.borders.bevel.is_some(); - let text = RichText::new(crate::i18n::tr_label(label)).size(t.font_label).color(if bevel { t.form_ink } else { t.on_accent }); + let text = RichText::new(crate::i18n::tr_label(label)).size(t.font_label); let response = egui::Frame::NONE .inner_margin(egui::Margin::symmetric(t.space_10 as i8, t.space_6 as i8)) .show(ui, |ui| { - let button = egui::Button::new(crate::fonts::RichTextExt::bold_face(text)) - .fill(if bevel { t.control_fill } else { t.form_primary_fill }) - .stroke(egui::Stroke::NONE) - .corner_radius(t.control_radius); + let button = styled_button(ui, text.bold_face().into(), ButtonTone::Primary).corner_radius(t.control_radius); ui.add_sized(vec2(dimension(ui.available_width()), t.form_button_height), button) }) .inner; diff --git a/crates/ui-egui/src/ui_kit/choice.rs b/crates/ui-egui/src/ui_kit/choice.rs index e5325c8368..d779bb12da 100644 --- a/crates/ui-egui/src/ui_kit/choice.rs +++ b/crates/ui-egui/src/ui_kit/choice.rs @@ -12,6 +12,8 @@ use egui::{Ui, vec2}; /// Text segments retain mixed states and optional compact clipping instead of dropdown fallback. #[derive(Default)] pub(crate) struct Segments<'a> { + /// Explicit focus requested by the owning form when a setting is opened for editing. + pub focus: Option, /// Per-option availability; omitted entries are enabled. Unavailable options stay faded. pub enabled: &'a [bool], /// Optional per-option refusal reasons. Disabled options remain visible and explain why. @@ -41,6 +43,7 @@ pub(crate) fn segmented(ui: &mut Ui, selected: &mut usize, options: &[&str], con crate::prefs_search::reveal(ui); } let Segments { + focus, enabled, disabled, targets, @@ -127,6 +130,9 @@ pub(crate) fn segmented(ui: &mut Ui, selected: &mut usize, options: &[&str], con let ui = &mut segment_ui; let segment_enabled = ui.is_enabled(); let response = ui.interact(segment, id, egui::Sense::click()); + if focus == Some(index) && segment_enabled && ui.is_visible() && ui.is_rect_visible(segment) { + response.request_focus(); + } let on = states.get(index).copied().unwrap_or(*selected == index); let rounding = egui::CornerRadius { nw: if index == 0 { t.control_radius } else { 0 }, @@ -337,14 +343,20 @@ pub(crate) fn checkbox_row(ui: &mut egui::Ui, key: &str, label: &str, on: bool) /// Compact bold/italic toggles for text. Tokens: scope font/control tokens. /// The glyphs keep the familiar B/I treatment; accessible names use translated style names. -pub(crate) fn text_emphasis(ui: &mut Ui, bold: &mut bool, italic: &mut bool) { +pub(crate) fn text_emphasis(ui: &mut Ui, bold: &mut bool, italic: &mut bool, bold_enabled: bool) { use crate::fonts::RichTextExt as _; - for (key, text, value, id) in [ - ("Bold", egui::RichText::new("B").semibold(), bold, "sketch_text.bold"), - ("Italic", egui::RichText::new("I").italics(), italic, "sketch_text.italic"), + for (key, text, value, id, enabled) in [ + ("Bold", egui::RichText::new("B").semibold(), bold, "sketch_text.bold", bold_enabled), + ("Italic", egui::RichText::new("I").italics(), italic, "sketch_text.italic", true), ] { - let response = ui.toggle_value(value, text).on_hover_text(crate::i18n::tr(id)); - response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), *value, crate::i18n::tr(id))); + if !enabled { + *value = false; + } + let mut response = ui.add_enabled_ui(enabled, |ui| ui.toggle_value(value, text).on_hover_text(crate::i18n::tr(id))).inner; + if !enabled { + response = response.on_disabled_hover_text(crate::i18n::tr("sketch_text.no_bold")); + } + response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled() && enabled, *value, crate::i18n::tr(id))); scenario::publish_target_as(&format!("dialog:{key}"), &format!("dialog:emphasis:{key}"), response.rect); } } diff --git a/crates/ui-egui/src/ui_kit/color.rs b/crates/ui-egui/src/ui_kit/color.rs index aaf5a54532..dfef5e72aa 100644 --- a/crates/ui-egui/src/ui_kit/color.rs +++ b/crates/ui-egui/src/ui_kit/color.rs @@ -1,5 +1,6 @@ //! Document RGB and optional separate opacity editing. Interface paint uses tokens; gradients are colour data. use super::{layout::label, metrics::dimension}; +use crate::recent_colors::{MAX_RECENT_COLORS, recent_colors, remember_color}; use crate::ui_kit::{Container, Surface}; use crate::{i18n, scenario, theme::Tokens}; use egui::{Color32, Response, Sense, Ui, ecolor::HsvaGamma, pos2, vec2}; @@ -122,6 +123,7 @@ fn color_popover_with_opacity(ui: &mut Ui, rgb: &mut [u8; 3], mut opacity: Optio let mut hexadecimal = ui.ctx().data(|d| d.get_temp::(format_id)).unwrap_or(false); let mut linear = ui.global_style().visuals.numeric_color_space == egui::style::NumericColorSpace::Linear; let mut accepted_mixed = false; + let mut picked_recent = false; ui.vertical(|ui| { ui.spacing_mut().item_spacing.y = t.space_10; ui.horizontal(|ui| { @@ -197,27 +199,14 @@ fn color_popover_with_opacity(ui: &mut Ui, rgb: &mut [u8; 3], mut opacity: Optio } }); } else { + let spin = if linear { + Spin { max: 1.0, step: 0.01, speed: 0.003, decimals: 3 } + } else { + Spin { max: 255.0, step: 1.0, speed: 0.5, decimals: 0 } + }; ui.columns(3, |columns| { for ((ui, channel), name) in columns.iter_mut().zip(channels.iter_mut()).zip(["R", "G", "B"]) { - ui.vertical(|ui| { - ui.spacing_mut().item_spacing.y = t.space_3; - ui.add(egui::Label::new(egui::RichText::new(name).size(t.font_small).color(t.form_dim))); - let response = ui.add_sized( - vec2(dimension(ui.available_width()), t.control_height), - egui::DragValue::new(channel) - .range(0.0..=if linear { 1.0 } else { 255.0 }) - .speed(if linear { 0.003 } else { 0.5 }) - .custom_parser(crate::prefs::parse_number) - .custom_formatter(move |v, _| if linear { format!("{v:.03}") } else { format!("{v:.0}") }), - ); - response.widget_info(|| { - let mut info = egui::WidgetInfo::drag_value(ui.is_enabled(), *channel); - info.label = Some(name.into()); - info - }); - publish(&format!("color:channel:{name}"), &response); - edited |= response.changed(); - }); + edited |= spin_channel(ui, name, None, channel, spin); } }); } @@ -233,6 +222,23 @@ fn color_popover_with_opacity(ui: &mut Ui, rgb: &mut [u8; 3], mut opacity: Optio } // Only graphical edits convert HSV back to RGB. Typed byte values stay exact. let mut graphical_edit = false; + // Hue in degrees, saturation and value in percent, of the same gamma HSV the square shows. + const HSV: [(&str, &str, f64); 3] = [("H", "color.hue", 360.0), ("S", "color.saturation", 100.0), ("V", "color.value", 100.0)]; + let mut hsv_fields = [hsv.h, hsv.s, hsv.v].iter().zip(HSV).map(|(v, (_, _, max))| f64::from(*v) * max).collect::>(); + let mut hsv_edited = false; + ui.columns(3, |columns| { + for ((ui, value), (name, tip, max)) in columns.iter_mut().zip(hsv_fields.iter_mut()).zip(HSV) { + hsv_edited |= spin_channel(ui, name, Some(i18n::tr(tip)), value, Spin { max, step: 1.0, speed: 0.5, decimals: 0 }); + } + }); + if hsv_edited { + let unit = |at: usize| { + let (value, max) = (hsv_fields.get(at).copied().unwrap_or(0.0), HSV.get(at).map_or(1.0, |h| h.2)); + if value.is_finite() { (value / max).clamp(0.0, 1.0) as f32 } else { 0.0 } + }; + (hsv.h, hsv.s, hsv.v) = (unit(0), unit(1), unit(2)); + graphical_edit = true; + } let width = dimension(ui.available_width()); let (square, response) = ui.allocate_exact_size(vec2(width, t.control_height * 7.0 + t.space_4), Sense::click_and_drag()); if (response.clicked() || response.dragged()) @@ -274,7 +280,33 @@ fn color_popover_with_opacity(ui: &mut Ui, rgb: &mut [u8; 3], mut opacity: Optio ); publish("color:current", &response); response.on_hover_text(i18n::tr("color.selected")); + if let Some(picked) = recent_row(ui, width) { + *rgb = picked; + picked_recent = true; + accepted_mixed = mixed; + hsv = crate::theme::srgb(rgb.map(|c| f32::from(c) / 255.0)).into(); + hex.set(*rgb, opacity.as_deref().copied()); + } }); + // A colour joins the recent list once an edit is finished: a drag or a held step button is + // released, a typed value is entered. Every picker shares the list (`crate::recent_colors`). + // While the popover stays open, fine-tuning replaces the colour it added rather than filling + // the list with each step; a colour taken from the list starts a new entry. + let session_id = id.with("recent_session"); + let pass = ui.ctx().cumulative_pass_nr(); + let mut session = ui.ctx().data(|d| d.get_temp::(session_id)).unwrap_or_default(); + if session.pass.saturating_add(1) < pass { + session = RecentSession::default(); + } + session.pass = pass; + session.pending |= *rgb != before_rgb; + if session.pending && !ui.input(|i| i.pointer.any_down()) { + let replacing = if picked_recent { None } else { session.entry }; + remember_color(ui.ctx(), *rgb, replacing); + session.entry = if picked_recent { None } else { Some(*rgb) }; + session.pending = false; + } + ui.ctx().data_mut(|d| d.insert_temp(session_id, session)); hex.sync(*rgb, opacity.as_deref().copied()); ui.ctx().data_mut(|d| d.insert_temp(id, (*rgb, hsv))); ui.ctx().data_mut(|d| { @@ -335,6 +367,161 @@ fn parse_hex(text: &str) -> Option<([u8; 3], Option)> { Some(([byte(0)?, byte(2)?, byte(4)?], if digits.len() == 8 { Some(byte(6)?) } else { None })) } +/// How a channel number edits: its range `0..=max`, the step of its step buttons, the drag +/// speed and the decimals shown. +#[derive(Clone, Copy)] +struct Spin { + max: f64, + step: f64, + speed: f64, + decimals: usize, +} + +/// One channel of the popover: its name above a drag field (drag, or click and type) and a pair +/// of step buttons for fine adjustment. Returns whether the value changed. +/// Tokens: space_2/3/14, control_height, font_small, form_dim. +fn spin_channel(ui: &mut Ui, name: &str, tip: Option<&str>, value: &mut f64, spin: Spin) -> bool { + let t = Tokens::get(); + let Spin { max, step, speed, decimals } = spin; + let mut changed = false; + ui.vertical(|ui| { + ui.spacing_mut().item_spacing.y = t.space_3; + let label = ui.add(egui::Label::new(egui::RichText::new(name).size(t.font_small).color(t.form_dim))); + if let Some(tip) = tip { + label.on_hover_text(tip); + } + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = t.space_2; + let width = dimension(ui.available_width() - t.space_14 - t.space_2); + let response = ui.add_sized( + vec2(width, t.control_height), + egui::DragValue::new(value) + .range(0.0..=max) + .speed(speed) + .custom_parser(crate::prefs::parse_number) + .custom_formatter(move |v, _| format!("{v:.decimals$}")), + ); + response.widget_info(|| { + let mut info = egui::WidgetInfo::drag_value(ui.is_enabled(), *value); + info.label = Some(tip.unwrap_or(name).into()); + info + }); + publish(&format!("color:channel:{name}"), &response); + let response = match tip { + Some(tip) => response.on_hover_text(tip), + None => response, + }; + changed |= response.changed(); + let steps = spin_buttons(ui, name, t.control_height); + if steps != 0 { + let current = if value.is_finite() { *value } else { 0.0 }; + *value = (current + f64::from(steps) * step).clamp(0.0, max); + changed = true; + } + }); + }); + changed +} + +/// Seconds a step button is held before it repeats, then between repeats. +const REPEAT_DELAY: f64 = 0.4; +const REPEAT_EVERY: f64 = 0.05; + +/// Step buttons stacked beside a number: a press steps once, holding repeats. Returns this +/// frame's steps (+ up, − down). Tokens: space_14, control_radius, hover, form_ink, form_dim. +fn spin_buttons(ui: &mut Ui, key: &str, height: f32) -> i32 { + let t = Tokens::get(); + let (rect, _) = ui.allocate_exact_size(vec2(t.space_14, height), Sense::hover()); + let (top, bottom) = rect.split_top_bottom_at_fraction(0.5); + let mut steps = 0; + for (direction, half, label, end) in [(1, top, "color.step_up", "up"), (-1, bottom, "color.step_down", "down")] { + let id = ui.id().with(("color_spin", key, end)); + let response = ui.interact(half, id, Sense::click()); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), i18n::tr(label))); + publish(&format!("color:spin:{key}:{end}"), &response); + let held = response.is_pointer_button_down_on(); + if response.hovered() || held { + ui.painter().rect_filled(half, t.control_radius, t.hover); + } + let ink = if response.hovered() || held { t.form_ink } else { t.form_dim }; + let side = half.height().min(half.width()); + let icon = egui::Rect::from_center_size(half.center(), vec2(side, side)); + if direction > 0 { + crate::icons::paint_ink_flipped(ui.painter(), icon, "chevron", ink); + } else { + crate::icons::paint_ink(ui.painter(), icon, "chevron", ink); + } + let repeat = id.with("repeat"); + if held { + let now = ui.input(|i| i.time); + let next = ui.ctx().data(|d| d.get_temp::(repeat)); + let due = match next { + None => Some(now + REPEAT_DELAY), + Some(next) if now >= next => Some(now + REPEAT_EVERY), + Some(_) => None, + }; + if let Some(due) = due { + steps += direction; + ui.ctx().data_mut(|d| d.insert_temp(repeat, due)); + } + ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(REPEAT_EVERY)); + } else { + ui.ctx().data_mut(|d| d.remove::(repeat)); + } + response.on_hover_text(i18n::tr(label)); + } + steps +} + +/// One popover's part in the recent list while it stays open (drawn on consecutive passes). +#[derive(Clone, Copy, Default)] +struct RecentSession { + /// The pass the popover was last drawn on; a gap means it was closed and opened again. + pass: u64, + /// The colour changed and the edit is not finished yet (a button is still down). + pending: bool, + /// The colour this opening added, replaced by the next finished edit. + entry: Option<[u8; 3]>, +} + +/// The recent colours as a row of samples (empty slots outlined); returns the one clicked. +/// Tokens: space_3/4/8, font_small, form_dim, control_radius/border, border, form_ink. +fn recent_row(ui: &mut Ui, width: f32) -> Option<[u8; 3]> { + let t = Tokens::get(); + let colors = recent_colors(ui.ctx()); + let mut picked = None; + ui.vertical(|ui| { + ui.spacing_mut().item_spacing.y = t.space_3; + ui.add(egui::Label::new(egui::RichText::new(i18n::tr("color.recent")).size(t.font_small).color(t.form_dim))); + let gap = t.space_4; + let slots = MAX_RECENT_COLORS as f32; + let side = ((width - gap * (slots - 1.0)) / slots).max(t.space_8); + let (row, response) = ui.allocate_exact_size(vec2(width, side), Sense::hover()); + publish("color:recent", &response); + if colors.is_empty() { + response.on_hover_text(i18n::tr("color.recent_help")); + } + for slot in 0..MAX_RECENT_COLORS { + let rect = egui::Rect::from_min_size(row.min + vec2(slot as f32 * (side + gap), 0.0), vec2(side, side)); + let Some(&[r, g, b]) = colors.get(slot) else { + ui.painter().rect_stroke(rect, t.control_radius, t.control_border_stroke(t.border), egui::StrokeKind::Inside); + continue; + }; + let response = ui.interact(rect, ui.id().with(("color_recent", slot)), Sense::click()); + let stroke = if response.hovered() { t.control_border_stroke(t.form_ink) } else { t.control_border_stroke(t.control_border) }; + ui.painter().rect(rect, t.control_radius, crate::theme::srgb([r, g, b].map(|c| f32::from(c) / 255.0)), stroke, egui::StrokeKind::Inside); + let hex = format!("#{r:02x}{g:02x}{b:02x}"); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &hex)); + publish(&format!("color:recent:{slot}"), &response); + let response = response.on_hover_cursor(egui::CursorIcon::PointingHand).on_hover_text(&hex); + if response.clicked() { + picked = Some([r, g, b]); + } + } + }); + picked +} + fn marker(ui: &Ui, at: egui::Pos2, radius: f32) { let t = Tokens::get(); ui.painter().circle_stroke(at, radius, egui::Stroke::new(t.space_3, t.border)); diff --git a/crates/ui-egui/src/ui_kit/color_tests.rs b/crates/ui-egui/src/ui_kit/color_tests.rs index 07bb7f1e2d..ffc05c0f05 100644 --- a/crates/ui-egui/src/ui_kit/color_tests.rs +++ b/crates/ui-egui/src/ui_kit/color_tests.rs @@ -337,3 +337,167 @@ fn rendered_square_hue_and_current_strip_leave_rounded_corner_pixels_clear() { } crate::theme::set_dark(previous); } + +/// A taller frame than the shared one: the popover's recent row sits below 400 points. +fn tall_in(ctx: &egui::Context, events: Vec, id: &str, rgb: &mut [u8; 3]) -> bool { + let mut changed = false; + scenario::clear_handles(); + let _ = crate::test_support::frame( + ctx, + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(320.0, 600.0))), + events, + focused: true, + ..Default::default() + }, + |ui| { + ui.set_width(280.0); + ui.push_id(id, |ui| crate::ui_kit::scope(ui, |ui| changed |= color_popover(ui, rgb))); + }, + ); + changed +} + +fn tall(ctx: &egui::Context, events: Vec, rgb: &mut [u8; 3]) -> bool { + tall_in(ctx, events, "picker", rgb) +} + +fn tall_click(ctx: &egui::Context, key: &str, rgb: &mut [u8; 3]) -> bool { + let at = scenario::handle_at(key).unwrap_or_else(|| panic!("missing {key}")); + tall(ctx, pointer(at, true), rgb) | tall(ctx, pointer(at, false), rgb) +} + +fn tall_type(ctx: &egui::Context, field: &str, text: &str, rgb: &mut [u8; 3]) { + tall_click(ctx, &format!("color:channel:{field}"), rgb); + let modifiers = egui::Modifiers { command: true, ctrl: true, ..Default::default() }; + tall( + ctx, + vec![egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers }, egui::Event::Text(text.into())], + rgb, + ); + tall(ctx, crate::ui_kit::test_support::key(egui::Key::Enter), rgb); +} + +fn hsv_bytes(h: f32, s: f32, v: f32) -> [u8; 3] { + let c = Color32::from(HsvaGamma { h, s, v, a: 1.0 }); + [c.r(), c.g(), c.b()] +} + +#[test] +fn step_buttons_nudge_rgb_and_hsv_channels_by_one() { + let ctx = crate::fonts::test_context(); + let mut rgb = [10, 20, 30]; + tall(&ctx, vec![], &mut rgb); + assert!(tall_click(&ctx, "color:spin:R:up", &mut rgb)); + assert_eq!(rgb, [11, 20, 30]); + assert!(tall_click(&ctx, "color:spin:B:down", &mut rgb)); + assert_eq!(rgb, [11, 20, 29]); + // Limits hold: 0 does not step below 0. + let mut rgb = [0, 0, 0]; + tall(&ctx, vec![], &mut rgb); + tall_click(&ctx, "color:spin:G:down", &mut rgb); + assert_eq!(rgb, [0, 0, 0]); + // H steps one degree, V one percent, on the same gamma HSV as the square. + let mut rgb = [255, 0, 0]; + tall(&ctx, vec![], &mut rgb); + tall_click(&ctx, "color:spin:H:up", &mut rgb); + assert_eq!(rgb, hsv_bytes(1.0 / 360.0, 1.0, 1.0)); + tall_click(&ctx, "color:spin:V:down", &mut rgb); + assert_eq!(rgb, hsv_bytes(1.0 / 360.0, 1.0, 0.99)); +} + +#[test] +fn typed_hsv_values_set_the_colour_within_their_ranges() { + let ctx = crate::fonts::test_context(); + let mut rgb = [255, 0, 0]; + tall(&ctx, vec![], &mut rgb); + tall_type(&ctx, "H", "120", &mut rgb); + tall_type(&ctx, "S", "50", &mut rgb); + assert_eq!(rgb, hsv_bytes(1.0 / 3.0, 0.5, 1.0)); + // A hostile typed value is bounded by the field's range. + tall_type(&ctx, "V", "1e300", &mut rgb); + assert_eq!(rgb, hsv_bytes(1.0 / 3.0, 0.5, 1.0)); +} + +#[test] +fn finished_edits_join_one_recent_list_that_every_picker_offers() { + let ctx = crate::fonts::test_context(); + let mut rgb = [10, 20, 30]; + tall(&ctx, vec![], &mut rgb); + assert!(recent_colors(&ctx).is_empty(), "opening a picker records nothing"); + // A drag across the square records only its final colour, once released. + let square = scenario::rect_of("color:sv").unwrap(); + let end = square.left_top() + vec2(10.0, 10.0); + tall(&ctx, pointer(square.center(), true), &mut rgb); + tall(&ctx, vec![egui::Event::PointerMoved(end)], &mut rgb); + assert!(recent_colors(&ctx).is_empty(), "nothing is recorded mid-drag"); + tall(&ctx, pointer(end, false), &mut rgb); + assert_eq!(recent_colors(&ctx), vec![rgb]); + // Fine-tuning in the same opening replaces that entry instead of adding one per step. + tall_click(&ctx, "color:spin:R:up", &mut rgb); + tall_click(&ctx, "color:spin:R:up", &mut rgb); + assert_eq!(recent_colors(&ctx), vec![rgb]); + let tuned = rgb; + // Closed (a pass without the popover) and opened again: the next edit is a new entry. + crate::ui_kit::test_support::frame(&ctx, vec![], |_| {}); + tall(&ctx, vec![], &mut rgb); + tall_click(&ctx, "color:spin:G:up", &mut rgb); + assert_eq!(recent_colors(&ctx), vec![rgb, tuned]); + let second = rgb; + // Another picker (another colour field of the app) shows the same list and takes from it. + let mut other = [200, 200, 200]; + tall_in(&ctx, vec![], "another_picker", &mut other); + assert!(scenario::handle_at("color:recent:2").is_none(), "empty slots are not buttons"); + let slot = scenario::handle_at("color:recent:1").unwrap(); + tall_in(&ctx, pointer(slot, true), "another_picker", &mut other); + assert!(tall_in(&ctx, pointer(slot, false), "another_picker", &mut other)); + assert_eq!(other, tuned); + assert_eq!(recent_colors(&ctx), vec![tuned, second], "a picked recent colour moves to the front"); + // Tuning a colour taken from the list adds a new entry and keeps the one it started from. + let up = scenario::handle_at("color:spin:B:up").unwrap(); + tall_in(&ctx, pointer(up, true), "another_picker", &mut other); + tall_in(&ctx, pointer(up, false), "another_picker", &mut other); + assert_eq!(recent_colors(&ctx), vec![other, tuned, second]); +} + +#[test] +fn recent_list_is_capped_unique_and_synced_with_the_saved_one() { + let ctx = crate::fonts::test_context(); + for i in 0..40u8 { + remember_color(&ctx, [i, 0, 0], None); + } + remember_color(&ctx, [35, 0, 0], None); + let recent = recent_colors(&ctx); + assert_eq!(recent.len(), MAX_RECENT_COLORS); + assert_eq!(recent.first(), Some(&[35, 0, 0])); + assert_eq!(recent.iter().filter(|c| **c == [35, 0, 0]).count(), 1); + // Picks flow to the saved list once… + let mut saved = Vec::new(); + crate::recent_colors::share(&ctx, &mut saved); + assert_eq!(saved, recent); + // …and a list loaded from preferences (even an oversized one) is what the pickers show. + let mut loaded: Vec<[u8; 3]> = (0..100u8).map(|i| [0, i, 0]).collect(); + crate::recent_colors::share(&ctx, &mut loaded); + assert_eq!(loaded.len(), MAX_RECENT_COLORS); + assert_eq!(recent_colors(&ctx), loaded); +} + +#[test] +fn recent_colours_are_saved_with_the_preferences_and_hostile_entries_dropped() { + use serde_json::json; + let mut app = crate::SolveApp::new(solidcraft_engine::Session::default(), Default::default()); + let mut list = vec![json!([1, 2, 3]), json!([300, 0, 0]), json!("red"), json!([1, 2]), json!(null)]; + list.extend((0..50).map(|i| json!([i, i, i]))); + app.load_prefs(&json!({ "recent_colors": list }).to_string()); + assert_eq!(app.ui.recent_colors.len(), MAX_RECENT_COLORS); + assert_eq!(app.ui.recent_colors.first(), Some(&[1, 2, 3])); + let ctx = crate::fonts::test_context(); + crate::recent_colors::share(&ctx, &mut app.ui.recent_colors); + remember_color(&ctx, [9, 8, 7], None); + crate::recent_colors::share(&ctx, &mut app.ui.recent_colors); + let mut restored = crate::SolveApp::new(solidcraft_engine::Session::default(), Default::default()); + restored.load_prefs(&app.prefs()); + assert_eq!(restored.ui.recent_colors.first(), Some(&[9, 8, 7])); + assert_eq!(restored.ui.recent_colors.get(1), Some(&[1, 2, 3])); + assert_eq!(restored.ui.recent_colors.len(), MAX_RECENT_COLORS); +} diff --git a/crates/ui-egui/src/ui_kit/container.rs b/crates/ui-egui/src/ui_kit/container.rs index d49407f6ce..1eabfa8345 100644 --- a/crates/ui-egui/src/ui_kit/container.rs +++ b/crates/ui-egui/src/ui_kit/container.rs @@ -58,6 +58,17 @@ impl DockEdges { pub const ALL: Self = Self { left: true, right: true, top: true, bottom: true }; } +thread_local! { + /// The areas built as [`Surface::Canvas`]: a value box, a dimension's editor, a readout. + static CANVAS_AREAS: std::cell::RefCell> = std::cell::RefCell::default(); +} + +/// Is `layer` a model-anchored editor or readout ([`Surface::Canvas`])? Such an area sits on the +/// model and scrolls nothing, so the model's wheel and trackpad navigation go on under it (#10061). +pub(crate) fn is_canvas(layer: egui::LayerId) -> bool { + CANVAS_AREAS.with(|areas| areas.borrow().contains(&layer.id)) +} + /// Universal outer host and surface policy. Both host geometry and frame construction must go /// through this API, including code in nested modules and platform apps. #[derive(Clone, Copy)] @@ -310,6 +321,9 @@ impl Container { } pub fn area(self, id: Id) -> egui::Area { + if self.surface == Surface::Canvas { + CANVAS_AREAS.with(|areas| areas.borrow_mut().insert(id)); + } // Workspace cards and viewport status stay below tool/system windows even when // clicked. Other areas keep the normal window layer; popups choose their order. let order = if matches!(self.surface, Surface::Panel | Surface::ViewportStatus) { egui::Order::Background } else { egui::Order::Middle }; diff --git a/crates/ui-egui/src/ui_kit/details.rs b/crates/ui-egui/src/ui_kit/details.rs new file mode 100644 index 0000000000..250a0c182e --- /dev/null +++ b/crates/ui-egui/src/ui_kit/details.rs @@ -0,0 +1,368 @@ +//! Description-driven details content. No tool, document or command knowledge lives here. +use crate::{fonts::RichTextExt as _, scenario, theme::Tokens}; +use egui::{RichText, Ui}; + +const FIELD_FOCUS: &str = "details_field_focus"; +const CONTROL_FOCUS: &str = "details_control_focus"; +const OWNED_POPUPS: &str = "details_owned_popups"; +pub(crate) fn dismiss_details_popups(ctx: &egui::Context) { + let popups = ctx.data(|data| data.get_temp::>(egui::Id::new(OWNED_POPUPS))).unwrap_or_default(); + for popup in popups { + egui::Popup::close_id(ctx, popup); + } +} +fn remember_control(ui: &Ui, id: egui::Id) { + if !ui.is_visible() || !ui.is_enabled() { + return; + } + ui.ctx().data_mut(|data| { + let key = egui::Id::new(CONTROL_FOCUS); + let mut ids = data.get_temp::>(key).unwrap_or_default(); + ids.push(id); + data.insert_temp(key, ids); + }); +} +pub(crate) fn details_control_owns_focus(ctx: &egui::Context) -> bool { + let ids = ctx.data(|data| data.get_temp::>(egui::Id::new(CONTROL_FOCUS))).unwrap_or_default(); + ctx.memory(|memory| memory.focused().is_some_and(|id| ids.contains(&id))) +} +pub(crate) fn details_field_owns_focus(ctx: &egui::Context) -> bool { + let fields = ctx.data(|data| data.get_temp::>(egui::Id::new(FIELD_FOCUS))).unwrap_or_default(); + let focused = ctx.memory(|memory| memory.focused()); + focused.is_some_and(|id| { + fields.iter().any(|(field, rect)| *field == id || ctx.read_response(id).is_some_and(|response| rect.contains(response.rect.center()))) + }) +} +pub(crate) fn details_field_owned_escape(ctx: &egui::Context) -> bool { + if details_field_owns_focus(ctx) { + return true; + } + let fields = ctx.data(|data| data.get_temp::>(egui::Id::new(FIELD_FOCUS))).unwrap_or_default(); + // egui blurs native value editors before global shortcuts see Escape. The + // preceding visible frame still owns that press, just as a text editor does. + ctx.memory(|memory| memory.focused().is_none() && fields.iter().any(|(id, _)| memory.had_focus_last_frame(*id))) +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum DetailValue { + Number { value: f64, min: f64, max: f64, suffix: String }, + Length { value: f64, units: String }, + Angle(f64), + Slider { value: f64, min: f64, max: f64 }, + Axis { value: [f64; 3], current: String }, + Check(bool), + Choice { selected: usize, options: Vec }, +} + +pub(crate) struct DetailSetting { + pub id: String, + pub label: String, + pub value: DetailValue, + pub enabled: bool, +} + +pub(crate) struct DetailKey { + pub colour: egui::Color32, + pub pattern: bool, + pub label: String, + pub value: String, + pub numeric: bool, + pub pattern_colour: Option, +} + +pub(crate) struct DetailFinding { + pub id: String, + pub label: String, + pub value: String, + pub warning: bool, + pub target: bool, +} + +/// All blocks are optional. Strings and numeric values are current caller-owned data. +#[derive(Default)] +pub(crate) struct Details { + pub caption: Option, + pub settings: Vec, + pub key: Vec, + pub findings_title: String, + pub findings_count: Option, + pub findings: Vec, + pub readout: Vec<(String, String)>, + pub actions: Vec<(String, String)>, +} + +#[derive(Debug, PartialEq)] +pub(crate) enum DetailEvent { + Change(String, DetailValue), + Target(String), + Action(String), +} + +fn heading(ui: &mut Ui, label: &str) { + heading_value(ui, label, None); +} +fn heading_value(ui: &mut Ui, label: &str, value: Option<&str>) { + let t = Tokens::get(); + super::divider(ui, label); + ui.horizontal(|ui| { + ui.label(RichText::new(label).semibold().size(t.font_small).color(t.text_dim)); + if let Some(value) = value { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label(RichText::new(value).monospace().size(t.font_small).color(t.text_dim)); + }); + } + }); +} + +/// Shared semantic swatch: pattern supplements the analysis colour in every vision mode. +pub(crate) fn patterned_swatch(ui: &mut Ui, colour: egui::Color32, pattern: bool, pattern_colour: Option) { + let t = Tokens::get(); + let (rect, _) = ui.allocate_exact_size(egui::vec2(t.space_12, t.space_12), egui::Sense::hover()); + ui.painter().rect_filled(rect, t.radius_sm, colour); + if pattern { + let painter = ui.painter().with_clip_rect(rect); + for offset in [-t.space_8, -t.space_4, 0.0, t.space_4, t.space_8] { + let from = rect.left_bottom() + egui::vec2(offset, 0.0); + painter.line_segment([from, from + egui::vec2(rect.height(), -rect.height())], t.primary_border(pattern_colour.unwrap_or(t.text))); + } + } +} + +fn setting(ui: &mut Ui, field: &DetailSetting, focus: bool) -> Option { + let mut value = field.value.clone(); + let mut changed = false; + super::row(ui, &field.label, |ui| { + ui.add_enabled_ui(field.enabled, |ui| { + ui.style_mut().drag_value_text_style = egui::TextStyle::Monospace; + let response = match &mut value { + DetailValue::Number { value, min, max, suffix } => Some(super::number_field(ui, value, *min..=*max, 0.01, suffix)), + DetailValue::Length { value, units } => Some(super::length_field(ui, value, units, 6)), + DetailValue::Angle(value) => Some(super::angle_field(ui, value)), + DetailValue::Slider { value, min, max } => Some(super::slider(ui, value, *min..=*max, Default::default())), + DetailValue::Check(value) => Some(super::checkbox(ui, "", value)), + DetailValue::Axis { value, .. } => { + let axes = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]; + let mut selected = axes.iter().position(|axis| *value == *axis).unwrap_or(usize::MAX); + let wanted_focus = (focus && ui.is_visible()).then_some(if selected < 3 { selected } else { 0 }); + for index in 0..3 { + remember_control(ui, ui.make_persistent_id(("segment", index))); + } + if super::segmented( + ui, + &mut selected, + &["X", "Y", "Z"], + super::Segments { focus: wanted_focus, compact: true, ..Default::default() }, + ) && let Some(axis) = axes.get(selected) + { + *value = *axis; + changed = true; + } + None + } + DetailValue::Choice { selected, options } => { + let before = *selected; + let current = options.get(*selected).cloned().unwrap_or_default(); + let response = super::dropdown_menu(ui, &field.id, ¤t, |ui| { + for (index, option) in options.iter().enumerate() { + let response = ui.selectable_value(selected, index, option); + remember_control(ui, response.id); + } + }); + ui.ctx().data_mut(|data| { + let key = egui::Id::new(OWNED_POPUPS); + let mut popups = data.get_temp::>(key).unwrap_or_default(); + // ComboBox's native popup is keyed by the returned button response. + popups.push(response.id.with("popup")); + data.insert_temp(key, popups); + }); + changed = *selected != before; + Some(response) + } + }; + if let Some(response) = response { + scenario::publish_target(&format!("details:field:{}", field.id), response.rect); + // Visibility reads Context::will_discard. Never re-enter Context while + // holding its data write lock. + let visible = ui.is_visible() && ui.is_enabled() && ui.is_rect_visible(response.rect); + if focus { + ui.scroll_to_rect(response.rect, None); + if visible { + response.request_focus(); + } + } + // A DragValue may focus its inner TextEdit rather than its outer + // response. Retain only the actual native editor inside this field. + let native_focus = ui.ctx().memory(|memory| memory.focused()).filter(|id| { + *id == response.id || ui.ctx().read_response(*id).is_some_and(|native| response.rect.contains(native.rect.center())) + }); + ui.ctx().data_mut(|data| { + let key = egui::Id::new(FIELD_FOCUS); + let mut fields = data.get_temp::>(key).unwrap_or_default(); + if visible { + fields.push((response.id, response.rect)); + if let Some(id) = native_focus { + fields.push((id, response.rect)); + } + } + data.insert_temp(key, fields); + }); + changed |= response.changed(); + } + }); + }); + if let DetailValue::Axis { value: vector, current } = &value + && ![[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]].contains(vector) + { + let response = ui.add(egui::Label::new(RichText::new(current).monospace()).wrap()).on_hover_text(current); + scenario::publish_target(&format!("details:current:{}", field.id), response.rect); + } + changed.then(|| DetailEvent::Change(field.id.clone(), value)) +} + +/// Fixed caption and actions surround one bounded scrolling body. The six-row findings +/// preview expands inside that body; it never creates another popup or steals CAD input. +pub(crate) fn details(ui: &mut Ui, id: &str, description: &Details, max_height: f32, focus: Option<&str>) -> Vec { + let t = Tokens::get(); + let mut events = Vec::new(); + ui.ctx().data_mut(|data| data.insert_temp(egui::Id::new(FIELD_FOCUS), Vec::<(egui::Id, egui::Rect)>::new())); + ui.ctx().data_mut(|data| data.insert_temp(egui::Id::new(CONTROL_FOCUS), Vec::::new())); + ui.ctx().data_mut(|data| data.insert_temp(egui::Id::new(OWNED_POPUPS), Vec::::new())); + ui.set_width((t.form_card_width + if description.findings.is_empty() { 0.0 } else { t.space_24 * 2.0 }).min(ui.available_width())); + super::scope(ui, |ui| { + let mut caption_height = 0.0; + if let Some(caption) = &description.caption { + let response = ui.add(egui::Label::new(RichText::new(caption).size(t.font_label).color(t.text_dim)).wrap()); + caption_height = response.rect.height() + t.space_8; + scenario::publish_rect("details:caption", response.rect); + } + let footer_height = if description.actions.is_empty() { 0.0 } else { t.control_height + t.space_16 }; + let body = egui::ScrollArea::vertical() + .id_salt(("details", id)) + .max_height((max_height - caption_height - footer_height - t.space_24).max(0.0)) + .min_scrolled_height(0.0) + .auto_shrink([false, true]) + .show(ui, |ui| { + if !description.settings.is_empty() { + heading(ui, crate::i18n::tr("ui.details_settings")); + super::settings_form(ui, t.form_label_width, |ui| { + for field in &description.settings { + ui.push_id(&field.id, |ui| { + if let Some(event) = setting(ui, field, focus == Some(field.id.as_str())) { + events.push(event); + } + }); + } + }); + } + if !description.key.is_empty() { + heading(ui, crate::i18n::tr("ui.details_key")); + for row in &description.key { + ui.horizontal(|ui| { + patterned_swatch(ui, row.colour, row.pattern, row.pattern_colour); + let label = RichText::new(&row.label); + ui.label(if row.numeric { label.monospace() } else { label }); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label(RichText::new(&row.value).monospace()); + }); + }); + } + } + if !description.findings.is_empty() { + heading_value(ui, &description.findings_title, description.findings_count.as_deref()); + let expanded_id = ui.make_persistent_id((id, "all_findings")); + let expanded = ui.data(|data| data.get_temp::(expanded_id)).unwrap_or(false); + let columns = [super::Column::new("", 1.0).flex(), super::Column::new("", 1.0).flex(), super::Column::new("", 0.0).icon_only()]; + super::table_custom(ui, id, &columns, "", |ui, table| { + for row in description.findings.iter().take(if expanded { usize::MAX } else { 6 }) { + table.row(ui, &row.id, false, |cells| { + cells.cell(|ui| { + ui.horizontal(|ui| { + if row.warning { + let (rect, _) = ui.allocate_exact_size(egui::vec2(t.space_16, t.space_16), egui::Sense::hover()); + crate::icons::paint_icon(ui.painter(), rect, "warning", 1.0); + } + ui.label(&row.label).on_hover_text(&row.label); + }); + }); + cells.cell(|ui| { + ui.label(RichText::new(&row.value).monospace().size(t.font_small)).on_hover_text(&row.value); + }); + cells.cell(|ui| { + if row.target { + let (rect, _) = ui.allocate_exact_size(egui::vec2(t.control_height, t.control_height), egui::Sense::hover()); + let response = ui + .push_id(&row.id, |ui| super::icon_button_at(ui, rect, "fit", crate::i18n::tr("ui.details_target"))) + .inner; + remember_control(ui, response.id); + scenario::publish_target(&format!("details:target:{}", row.id), response.rect); + if response.clicked() { + events.push(DetailEvent::Target(row.id.clone())); + } + } + }); + }); + } + }); + scenario::publish_count( + "details:findings_visible", + if expanded { description.findings.len() } else { description.findings.len().min(6) } as f64, + ); + if !expanded && description.findings.len() > 6 { + let text = crate::i18n::format("ui.details_show_all", &[("count", &description.findings.len().to_string())]); + let response = super::button(ui, text, super::Button::Ghost); + remember_control(ui, response.id); + scenario::publish_target("details:show_all", response.rect); + if response.clicked() { + ui.data_mut(|data| data.insert_temp(expanded_id, true)); + } + } + } else if !description.readout.is_empty() { + heading(ui, crate::i18n::tr("ui.details_readout")); + let expanded_id = ui.make_persistent_id((id, "all_readout")); + let expanded = ui.data(|data| data.get_temp::(expanded_id)).unwrap_or(false); + let columns = [super::Column::new("", 1.0).flex(), super::Column::new("", 1.0).flex()]; + super::table_custom(ui, id, &columns, "", |ui, table| { + for (index, (label, value)) in description.readout.iter().take(if expanded { usize::MAX } else { 6 }).enumerate() { + table.row(ui, &index.to_string(), false, |row| { + row.cell(|ui| { + ui.label(label); + }); + row.cell(|ui| { + ui.label(RichText::new(value).monospace()); + }); + }); + } + }); + scenario::publish_count( + "details:readout_visible", + if expanded { description.readout.len() } else { description.readout.len().min(6) } as f64, + ); + if !expanded && description.readout.len() > 6 { + let text = crate::i18n::format("ui.details_show_all", &[("count", &description.readout.len().to_string())]); + let response = super::button(ui, text, super::Button::Ghost); + remember_control(ui, response.id); + scenario::publish_target("details:show_all", response.rect); + if response.clicked() { + ui.data_mut(|data| data.insert_temp(expanded_id, true)); + } + } + } + }); + scenario::publish_rect("details:body", body.inner_rect); + if !description.actions.is_empty() { + super::divider(ui, "details_actions"); + let actions = ui.horizontal(|ui| { + for (id, label) in description.actions.iter().take(2) { + let response = super::text_button(ui, label, true); + remember_control(ui, response.id); + scenario::publish_target(&format!("details:action:{id}"), response.rect); + if response.clicked() { + events.push(DetailEvent::Action(id.clone())); + } + } + }); + scenario::publish_rect("details:actions", actions.response.rect); + } + }); + events +} diff --git a/crates/ui-egui/src/ui_kit/fields.rs b/crates/ui-egui/src/ui_kit/fields.rs index 7718a7464f..51e39ae34d 100644 --- a/crates/ui-egui/src/ui_kit/fields.rs +++ b/crates/ui-egui/src/ui_kit/fields.rs @@ -265,6 +265,9 @@ fn editor_reserving(ui: &mut Ui, value: &mut String, hint: &str, completion: boo edit = edit.margin(egui::Margin { left: inset, right: inset.saturating_add(reserve.round() as i8), top: 0, bottom: 0 }); } let mut response = ui.add_sized(vec2(width, row_height()), edit); + if response.has_focus() { + ui.ctx().data_mut(|data| data.insert_temp(egui::Id::new("sc_focused_editor"), response.id)); + } super::text_menu::menu(ui, &mut response, value, super::text_menu::Field::LINE); if completion && ui.is_enabled() { ui.ctx().data_mut(|data| { @@ -453,6 +456,7 @@ pub(crate) fn selection_field(ui: &mut Ui, text: &str, options: Selection) -> Re publish(ui, &response, egui::WidgetType::Button); scenario::publish_rect(&format!("dialog:field:{}", label(ui)), rect); scenario::publish_count(&format!("dialog:selection:{}:filled", label(ui)), f64::from(filled)); + scenario::publish_count(&format!("dialog:selection:{}:active", label(ui)), f64::from(active)); response } @@ -1120,15 +1124,12 @@ fn grown_width(ui: &Ui, value: &str, font: &egui::FontId, base: f32) -> f32 { pub(crate) fn placement_field(ui: &mut Ui, id: egui::Id, value: &mut String, hint: &str) -> Response { let t = Tokens::get(); let font = egui::FontId::proportional(t.font_body); - // Canvas editors must retain their native hit target: enlarging it intercepts nearby - // sketch picks. Keep the former line + space_2 above/below, with shared galley alignment. - let height = ui.fonts_mut(|fonts| fonts.row_height(&font)) + ui.spacing().extra_text_line_spacing + t.space_4; - let width = grown_width(ui, value, &font, t.control_height * 3.0 - t.space_6); + let size = placement_field_content_size(ui, value); let mut response = ui.add( text_input(egui::TextEdit::singleline(value)) .id(id) - .min_size(vec2(width, height)) - .desired_width(width) + .min_size(size) + .desired_width(size.x) .return_key(None) .event_filter(egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, ..Default::default() }) .font(font) @@ -1186,3 +1187,13 @@ pub(crate) fn inline_name(ui: &mut Ui, rect: egui::Rect, id: egui::Id, value: &m super::text_menu::menu(ui, &mut response, value, super::text_menu::Field::LINE); response } + +/// The placement editor's content size, before its horizontal text margins and outer frame. +/// Floating owners use the same measurement to place adjacent editors without overlap. +pub(crate) fn placement_field_content_size(ui: &Ui, value: &str) -> egui::Vec2 { + let t = Tokens::get(); + let font = egui::FontId::proportional(t.font_body); + // Retain the native hit target: a taller editor would intercept nearby sketch picks. + let height = ui.fonts_mut(|fonts| fonts.row_height(&font)) + ui.spacing().extra_text_line_spacing + t.space_4; + vec2(grown_width(ui, value, &font, t.control_height * 3.0 - t.space_6), height) +} diff --git a/crates/ui-egui/src/ui_kit/gallery.rs b/crates/ui-egui/src/ui_kit/gallery.rs index 4c32a2d099..3027ff092a 100644 --- a/crates/ui-egui/src/ui_kit/gallery.rs +++ b/crates/ui-egui/src/ui_kit/gallery.rs @@ -1156,6 +1156,9 @@ const ELEMENTS: &[Element] = &[ element!("text", "dialog:report:gallery", Some("dialog:report:gallery"), |ui, _| { text(ui, "Validation report with retained units: 10 mm", Text::Report("gallery", false)); }), + element!("name_cell_with_hint", "dialog:cell:Width", Some("dialog:cell:Width"), |ui, _| { + name_cell_with_hint(ui, "Width", false, "Double-click to rename"); + }), element!("search_field", |ui, s| { search_field(ui, ui.id().with("search"), &mut s.value, "Search"); }), @@ -1289,7 +1292,7 @@ const ELEMENTS: &[Element] = &[ }), element!("text_emphasis", "dialog:row:Gallery", Some("dialog:Bold"), |ui, s| { row(ui, "Gallery", |ui| { - text_emphasis(ui, &mut s.checked, &mut false); + text_emphasis(ui, &mut s.checked, &mut false, true); }); }), element!("value_pair_named", "dialog:row:Position", Some("dialog:Distance X"), |ui, s| { @@ -1771,7 +1774,7 @@ fn emphasis_and_compact_modes_keep_independent_actions_in_both_themes() { let mut italic = false; let mut selected = 0; let mut draw = |ui: &mut Ui| { - row(ui, "Style", |ui| text_emphasis(ui, &mut bold, &mut italic)); + row(ui, "Style", |ui| text_emphasis(ui, &mut bold, &mut italic, true)); row(ui, "Alignment", |ui| { segmented( ui, @@ -1939,8 +1942,8 @@ fn gallery_covers_every_inline_element() { && matches!(&item.vis, syn::Visibility::Restricted(v) if v.path.is_ident("crate")) { let name = item.sig.ident.to_string(); - // The inherited row label is metadata, not a renderable element. - if name != "label" { + // Inherited labels, state keys and size measurements do not render elements. + if !matches!(name.as_str(), "eye_drag_id" | "label" | "placement_field_content_size") { assert!(names.contains(name.as_str()), "{file}: {name} needs a gallery entry"); } } diff --git a/crates/ui-egui/src/ui_kit/layout.rs b/crates/ui-egui/src/ui_kit/layout.rs index f598e3c266..d299fc7ed9 100644 --- a/crates/ui-egui/src/ui_kit/layout.rs +++ b/crates/ui-egui/src/ui_kit/layout.rs @@ -678,6 +678,15 @@ pub(crate) fn note_height(ui: &Ui, text: &str) -> f32 { } pub(crate) fn text(ui: &mut Ui, text: &str, style: Text<'_>) -> Response { + text_impl(ui, text, style, None) +} + +/// One name-cell tooltip: the action hint, with the full name only if the cell clips it. +pub(crate) fn name_cell_with_hint(ui: &mut Ui, name: &str, strong: bool, hint: &str) -> Response { + text_impl(ui, name, Text::NameCell(strong), Some(hint)) +} + +fn text_impl(ui: &mut Ui, text: &str, style: Text<'_>, hover_hint: Option<&str>) -> Response { if crate::prefs_search::querying(ui) { let heading = matches!(style, Text::Heading(_)); if heading { @@ -718,9 +727,16 @@ pub(crate) fn text(ui: &mut Ui, text: &str, style: Text<'_>) -> Response { vec2(dimension(ui.available_width()), row_height()), if matches!(style, Text::Value(_, _)) { egui::Sense::hover() } else { egui::Sense::click() }, ); + let tooltip = hover_hint.map(|hint| { + if ui.painter().layout_no_wrap(text.to_owned(), font.clone(), ink).size().x > rect.width() { + format!("{text}\n{hint}") + } else { + hint.to_owned() + } + }); paint_text(ui, rect, text, font, ink, false); response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, ui.is_enabled(), text)); - response.on_hover_text(text) + response.on_hover_text(tooltip.as_deref().unwrap_or(text)) } else if matches!(style, Text::Error(_)) { egui::Frame::NONE .fill(t.form_error_fill) diff --git a/crates/ui-egui/src/ui_kit/mod.rs b/crates/ui-egui/src/ui_kit/mod.rs index 78800da043..4e5d5eede0 100644 --- a/crates/ui-egui/src/ui_kit/mod.rs +++ b/crates/ui-egui/src/ui_kit/mod.rs @@ -9,6 +9,7 @@ mod choice; mod color; mod confirm; pub mod container; +mod details; mod disclosure; mod drag; mod fields; @@ -27,6 +28,7 @@ pub(crate) mod panel; mod preferences; mod preferences_assistant; mod preferences_figures; +mod preferences_layout; mod preferences_navigation; mod preferences_small; mod radial; @@ -57,6 +59,7 @@ pub(crate) use choice::*; pub(crate) use color::*; pub(crate) use confirm::{Confirm, Tone}; pub use container::{Container, DockEdges, Edge, Surface}; +pub(crate) use details::*; pub(crate) use disclosure::*; pub(crate) use drag::*; pub(crate) use fields::*; diff --git a/crates/ui-egui/src/ui_kit/panel.rs b/crates/ui-egui/src/ui_kit/panel.rs index 0917a0abba..a9d3c09148 100644 --- a/crates/ui-egui/src/ui_kit/panel.rs +++ b/crates/ui-egui/src/ui_kit/panel.rs @@ -671,6 +671,17 @@ impl<'a> Panel<'a> { } pub(crate) fn window(&self, ctx: &egui::Context) -> egui::Window<'a> { + self.window_placed(ctx, None) + } + + /// The same window for contents the caller holds to `size` (egui's `fixed_size`); the card + /// is that plus its frame. + pub(crate) fn fixed_window(&self, ctx: &egui::Context, size: egui::Vec2) -> egui::Window<'a> { + let outer = size.x + self.positioned_frame(ctx).total_margin().sum().x; + self.window_placed(ctx, Some(outer)).fixed_size(size) + } + + fn window_placed(&self, ctx: &egui::Context, outer_width: Option) -> egui::Window<'a> { let gap = super::Container::new(ctx, super::Surface::Feature).gap(); let viewport = self.viewport; let anchor = self.bounded_anchor(ctx); @@ -680,17 +691,22 @@ impl<'a> Panel<'a> { let width = self.width.min(viewport.width().max(0.0)); let inner_width = (width - margin.sum().x).max(0.0); let height = (viewport.bottom() - anchor.y - gap).max(0.0); + // Place the card by its left edge, worked out from the outer width the window is held to. + // Pivoting on the right edge subtracts the size egui measured last frame, and at a + // fractional display scale rounding the left edge to a physical pixel changes the next + // measured width by one, so the card hopped a pixel back and forth on every repaint (#9457). + let left_top = match side { + crate::frame::Side::Right => egui::pos2(anchor.x - outer_width.unwrap_or(inner_width), anchor.y), + crate::frame::Side::Left => anchor, + }; // Keep drawing safe when even the header/footer cannot fit. egui clips the contents to // the remaining viewport; the normal small-height case still keeps both actions visible. super::Container::new(ctx, super::Surface::Feature) .window(self.title) .id(self.id) .title_bar(false) - .pivot(match side { - crate::frame::Side::Right => egui::Align2::RIGHT_TOP, - crate::frame::Side::Left => egui::Align2::LEFT_TOP, - }) - .fixed_pos(anchor) + .pivot(egui::Align2::LEFT_TOP) + .fixed_pos(left_top) .constrain(false) .collapsible(false) .resizable(false) @@ -835,6 +851,8 @@ impl<'a> Panel<'a> { /// Tokens: space_24 and form_footer_height; header/body/footer own their historical padding. pub(crate) fn palette(&self, ui: &mut Ui, bottom: f32, top: f32, mut add: impl FnMut(&mut Ui, Part)) { let t = Tokens::get(); + // Dialog buttons at top (#9860): the footer row moves under the header, above the options. + let at_top = DialogButtons::current(ui.ctx()) == DialogButtons::Top; ui.set_max_height((bottom - ui.cursor().top()).max(0.0)); super::scope(ui, |ui| { ui.spacing_mut().item_spacing.y = 0.0; @@ -842,7 +860,11 @@ impl<'a> Panel<'a> { let short = height < t.space_24 * 6.0; let mut contents = |ui: &mut Ui| { add(ui, Part::Header); - let body_height = (bottom - ui.cursor().top() - t.form_footer_height).max(0.0); + if at_top { + add(ui, Part::Footer); + } + let footer_height = if at_top { 0.0 } else { t.form_footer_height }; + let body_height = (bottom - ui.cursor().top() - footer_height).max(0.0); let mut body = |ui: &mut Ui| { egui::Frame::NONE.inner_margin(egui::Margin::symmetric(t.space_10 as i8, t.space_4 as i8)).show(ui, |ui| add(ui, Part::Body)); }; @@ -862,7 +884,9 @@ impl<'a> Panel<'a> { .inner; scenario::publish_rect("sketch_palette:options", scroll.inner_rect); } - add(ui, Part::Footer); + if !at_top { + add(ui, Part::Footer); + } }; if short { let scroll = egui::ScrollArea::vertical() @@ -1082,7 +1106,7 @@ impl<'a> Window<'a> { state: &mut S, left: impl FnOnce(&mut Ui), ) -> (bool, super::Action) { - self.show_pages_inner(ctx, selected, pages, state, None, None, left) + self.show_pages_inner(ctx, selected, pages, state, None, None, None, left) } /// Page navigation with a strip above the page body (Preferences search). @@ -1095,9 +1119,10 @@ impl<'a> Window<'a> { state: &mut S, header: fn(&mut Ui, &mut S), on_select: fn(&mut S), + sidebar_footer: Option<(f32, fn(&mut Ui, &mut S))>, left: impl FnOnce(&mut Ui), ) -> (bool, super::Action) { - self.show_pages_inner(ctx, selected, pages, state, Some(header), Some(on_select), left) + self.show_pages_inner(ctx, selected, pages, state, Some(header), Some(on_select), sidebar_footer, left) } fn show_pages_inner( @@ -1108,13 +1133,14 @@ impl<'a> Window<'a> { state: &mut S, header: Option, on_select: Option, + sidebar_footer: Option<(f32, fn(&mut Ui, &mut S))>, left: impl FnOnce(&mut Ui), ) -> (bool, super::Action) { self.body_padding = false; self.page_chrome = true; let rows = self.page_rows; let padding = self.page_padding; - self.show_with_left(ctx, true, |ui| page_list_sized(ui, selected, pages, state, rows, padding, header, on_select), left) + self.show_with_left(ctx, true, |ui| page_list_sized(ui, selected, pages, state, rows, padding, header, on_select, sidebar_footer), left) } /// The same bounded card frame, either movable or an input-blocking modal. @@ -1182,7 +1208,7 @@ pub(crate) struct WindowPage { #[cfg(test)] pub(super) fn page_list(ui: &mut Ui, selected: &mut usize, pages: &[WindowPage], state: &mut S) { - page_list_sized(ui, selected, pages, state, 16.0, egui::Margin::same(Tokens::get().space_16 as i8), None, None); + page_list_sized(ui, selected, pages, state, 16.0, egui::Margin::same(Tokens::get().space_16 as i8), None, None, None); } fn page_list_sized( @@ -1194,6 +1220,7 @@ fn page_list_sized( padding: egui::Margin, header: Option, on_select: Option, + sidebar_footer: Option<(f32, fn(&mut Ui, &mut S))>, ) { let t = Tokens::get(); // The search strip, the gap under it, and the following item spacing sit above the page. @@ -1213,10 +1240,19 @@ fn page_list_sized( t.secondary_vline(ui.painter(), nav.right(), nav.y_range(), t.dialog_rule); scenario::publish_rect("dialog:pages", nav); *selected = (*selected).min(pages.len().saturating_sub(1)); - ui.scope_builder(egui::UiBuilder::new().max_rect(nav.shrink2(vec2(t.space_8, t.space_6))), |ui| { + let nav_inner = nav.shrink2(vec2(t.space_8, t.space_6)); + let footer_height = sidebar_footer.map_or(0.0, |(height, _)| dimension(height).min(nav_inner.height())); + let list = egui::Rect::from_min_max(nav_inner.min, egui::pos2(nav_inner.right(), nav_inner.bottom() - footer_height)); + ui.scope_builder(egui::UiBuilder::new().max_rect(list), |ui| { ui.set_clip_rect(ui.clip_rect().intersect(nav)); ui.spacing_mut().item_spacing.y = t.space_2; - egui::ScrollArea::vertical().id_salt("window_pages").show(ui, |ui| { + let selection_id = ui.id().with("page_selection"); + let changed = ui.data_mut(|data| { + let previous = data.get_temp::(selection_id); + data.insert_temp(selection_id, *selected); + previous != Some(*selected) + }); + egui::ScrollArea::vertical().id_salt("window_pages").max_height(list.height()).show(ui, |ui| { for (index, page) in pages.iter().enumerate() { if let Some(group) = page.group { ui.add_space(t.space_8); @@ -1234,6 +1270,9 @@ fn page_list_sized( } } let on = *selected == index; + if on && changed && sidebar_footer.is_some() && !list.contains_rect(rect) { + response.scroll_to_me(Some(egui::Align::Center)); + } if on || response.hovered() || response.has_focus() { ui.painter().rect_filled(rect, t.control_radius, if on { t.selected_bg } else { t.hover }); } @@ -1259,6 +1298,14 @@ fn page_list_sized( } }); }); + if let Some((_, footer)) = sidebar_footer { + let rect = egui::Rect::from_min_max(egui::pos2(nav_inner.left(), list.bottom()), nav_inner.max); + ui.scope_builder(egui::UiBuilder::new().id_salt("pages_footer").max_rect(rect), |ui| { + ui.set_clip_rect(ui.clip_rect().intersect(rect)); + footer(ui, state); + }); + scenario::publish_rect("dialog:pages_footer", rect); + } let content = egui::Rect::from_min_max(egui::pos2(nav.right(), body.top()), body.max); let padding = egui::epaint::MarginF32 { left: padding.leftf().min(content.width() / 2.0), diff --git a/crates/ui-egui/src/ui_kit/panel_tests.rs b/crates/ui-egui/src/ui_kit/panel_tests.rs index 616c86d1b2..ea676ffd2a 100644 --- a/crates/ui-egui/src/ui_kit/panel_tests.rs +++ b/crates/ui-egui/src/ui_kit/panel_tests.rs @@ -856,6 +856,19 @@ fn preferences_preserves_every_page_and_footer_in_both_themes() { } assert!(scenario::rect_of("prefs:page_body").is_some()); assert!(h.widget(h.app.tr("ui.restore_defaults")).is_some()); + let footer = scenario::rect_of("dialog:pages_footer").expect("pinned profile footer"); + assert!(nav.contains_rect(footer)); + assert!(footer.contains_rect(scenario::rect_of("prefs:profile").expect("real switcher"))); + let tagged = [ + crate::prefs::PAGE_GENERAL, + crate::prefs::PAGE_APPEARANCE, + crate::prefs::PAGE_LAYOUT, + crate::prefs::PAGE_SKETCH, + crate::prefs::PAGE_NAVIGATION, + crate::prefs::PAGE_SHORTCUTS, + ] + .contains(&index); + assert_eq!(scenario::rect_of("prefs:in_profile").is_some(), tagged, "{page}"); } } crate::theme::set_dark(previous); @@ -1281,3 +1294,30 @@ fn a_left_panel_in_an_inside_out_view_keeps_an_anchor() { let anchor = panel.bounded_anchor(&ctx); assert!(anchor.x.is_finite() && anchor.y.is_finite(), "{anchor:?}"); } + +#[test] +fn page_sidebar_footer_stays_visible_when_the_page_list_scrolls() { + for theme in ["light", "dark"] { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.app.preferences.theme = theme.into(); + h.call("ui.resize", serde_json::json!({"width":1200,"height":620})); + crate::prefs::open_at(&mut h.app, crate::prefs::PAGE_GENERAL); + h.frames(6); + let footer = scenario::rect_of("dialog:pages_footer").unwrap(); + let nav = scenario::rect_of("dialog:pages").unwrap(); + h.frame_with(vec![ + egui::Event::PointerMoved(egui::pos2(nav.center().x, nav.top() + 30.0)), + egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -1000.0), + modifiers: egui::Modifiers::NONE, + phase: egui::TouchPhase::Move, + }, + ]); + h.frames(60); + assert_eq!(scenario::rect_of("dialog:pages_footer"), Some(footer)); + assert!(footer.contains_rect(scenario::rect_of("prefs:profile").unwrap())); + assert!(nav.contains_rect(scenario::rect_of("prefs:page:Plugins").unwrap()), "last page is reachable in {theme}"); + } +} diff --git a/crates/ui-egui/src/ui_kit/preferences.rs b/crates/ui-egui/src/ui_kit/preferences.rs index 42595ed47c..0275e5ee71 100644 --- a/crates/ui-egui/src/ui_kit/preferences.rs +++ b/crates/ui-egui/src/ui_kit/preferences.rs @@ -9,6 +9,310 @@ use egui::{Color32, FontId, Pos2, Rect, Response, Sense, Stroke, Ui, pos2, vec2} pub(super) const PAGE_SCROLL_GUTTER: &str = "sc_page_scroll_gutter"; +/// Presentation data is resolved by the caller; controls never distinguish standard/custom ids. +pub(crate) struct ThemeOptionRow<'a> { + pub definition: &'a crate::theme_files::OptionDef, + pub label: String, + pub help: String, + pub choices: Vec, + pub reason: Option, +} + +#[derive(Clone, Copy)] +struct ThemeOptionMetrics { + label: f32, + readout: f32, + reason: f32, + minimum: f32, +} + +fn theme_option_slider_minimum(t: &Tokens) -> f32 { + t.space_24 * 2.0 + t.space_8 +} + +/// Measure the same fonts, gutters and control sizes used by `theme_option_control`. +fn theme_option_metrics(ui: &Ui, option: &ThemeOptionRow<'_>, value: Option<&crate::theme_files::OptionValue>) -> ThemeOptionMetrics { + use crate::theme_files::{OptionKind, OptionValue}; + let t = Tokens::get(); + let label = super::ContentWidth::label(ui, &option.label) + t.space_4; + let mut readout = 0.0; + let control = match &option.definition.kind { + OptionKind::Bool { .. } => t.space_24 + t.space_8, + OptionKind::Range { default, min, max, step, unit } => { + let current = match value { + Some(OptionValue::Num(n)) => *n, + _ => *default, + }; + // Endpoints and adjacent steps reserve fractional readouts before the first edit. + readout = [*min, *max, *default, current, (*min + *step).min(*max), (*max - *step).max(*min)] + .into_iter() + .map(|n| super::ContentWidth::label(ui, &format!("{n}{}", unit.as_deref().unwrap_or("")))) + .fold(0.0, f32::max); + theme_option_slider_minimum(&t) + t.space_8 + readout + } + OptionKind::Choice { values, .. } => { + if values.len() > 5 { + let labels: Vec<_> = option.choices.iter().map(String::as_str).collect(); + super::ContentWidth::dropdown(ui, &labels).min(t.form_label_width + t.space_24) + } else { + // Compact segments use equal widths based on the widest semibold label. + let widest = option.choices.iter().map(|s| super::ContentWidth::name(ui, s)).fold(0.0, f32::max); + (widest + t.space_16) * values.len() as f32 + } + } + }; + let reason = option + .reason + .as_ref() + .map_or(0.0, |reason| ui.painter().layout_no_wrap(reason.clone(), FontId::proportional(t.font_body), t.form_dim).size().x.ceil() + t.space_8); + // settings_form/row use the measured label plus a fixed space_14 gutter. + ThemeOptionMetrics { label, readout, reason, minimum: label + t.space_14 + control + reason } +} + +/// A quiet badge drawn within the existing theme card, without changing its hit target or size. +pub(crate) fn theme_options_badge(ui: &Ui, card: Rect, label: &str) { + let t = Tokens::get(); + let text = ui.painter().layout_no_wrap(label.to_owned(), FontId::proportional(t.font_micro), t.form_ink); + let size = vec2(text.size().x + t.space_8, t.space_16); + let rect = Rect::from_min_size(card.left_top() + vec2(t.space_6, t.space_6), size); + ui.painter().rect_filled(rect, t.radius_sm, t.dialog_body); + ui.painter().galley(rect.center() - text.size() / 2.0, text, t.form_ink); + scenario::publish_rect("prefs:theme_options_badge", rect); +} + +/// Ordered two-column list, with composite children sharing their parent's cell and hit row. +/// Returns Reset; edited values are returned through `values` for the caller's preference merge. +pub(crate) fn theme_options_list( + ui: &mut Ui, + theme_id: &str, + name: &str, + options: &[ThemeOptionRow<'_>], + values: &mut std::collections::BTreeMap, +) -> bool { + use super::preferences_layout::{Entry, Width, choice_width, layout}; + use crate::theme_files::OptionKind; + // Label/help/choice text is already resolved, including the theme's English fallback. + let _labels = crate::i18n::resolved_labels(); + if options.is_empty() { + return false; + } + let t = Tokens::get(); + let mut cells: Vec> = Vec::new(); + for (i, option) in options.iter().enumerate() { + if option.definition.parent.as_ref().is_some_and(|id| options.iter().any(|o| &o.definition.id == id)) { + continue; + } + let mut cell = vec![i]; + let mut parent = option.definition.id.as_str(); + for _ in 0..crate::theme_files::MAX_OPTIONS { + let Some((j, child)) = options.iter().enumerate().find(|(_, o)| o.definition.parent.as_deref() == Some(parent)) else { break }; + if cell.contains(&j) { + break; + } + cell.push(j); + parent = &child.definition.id; + } + cells.push(cell); + } + let metrics: Vec<_> = options.iter().map(|option| theme_option_metrics(ui, option, values.get(&option.definition.id))).collect(); + let entries: Vec<_> = cells + .iter() + .map(|cell| { + let mut width = if cell.len() > 1 { Width::Composite } else { Width::Narrow }; + let mut minimum = t.space_8 * cell.len().saturating_sub(1) as f32; + let mut advanced = false; + for &index in cell { + let Some(option) = options.get(index) else { continue }; + advanced |= option.definition.advanced; + if let OptionKind::Choice { values, .. } = &option.definition.kind + && choice_width(values.len()) == Width::Wide + { + width = Width::Wide; + } + minimum += metrics.get(index).map_or(0.0, |m| m.minimum); + } + Entry { width, advanced, minimum } + }) + .collect(); + let plan = layout(&entries, dimension(ui.available_width()) - t.space_16); + let mut reset = false; + let header = ui.horizontal(|ui| { + super::text(ui, name, super::Text::Inline); + if options.iter().any(|option| values.get(&option.definition.id).is_some_and(|v| *v != option.definition.default_value())) { + let response = super::text_button(ui, crate::i18n::tr("prefs.theme_options_reset"), true); + scenario::publish_target("prefs:theme_options_reset", response.rect); + reset = response.clicked(); + } + }); + scenario::publish_rect("prefs:theme_options_header", header.response.rect); + let mut draw_rows = |ui: &mut Ui, rows: &[Vec]| { + let mut section = None; + for row in rows { + // Labels are theme-authored metadata, shown only above the six-cell threshold. + let label = + row.iter().filter_map(|i| cells.get(*i)).flatten().filter_map(|i| options.get(*i)).find_map(|o| o.definition.section.as_deref()); + if plan.section_labels && label != section { + if let Some(label) = label { + super::text(ui, label, super::Text::Hint("theme_option_section")); + } + section = label; + } + let width = dimension(ui.available_width()); + let cell_width = (width - t.space_16) / 2.0; + ui.horizontal_top(|ui| { + ui.spacing_mut().item_spacing.x = t.space_16; + for &index in row { + let Some(cell) = cells.get(index) else { continue }; + let full = entries.get(index).is_some_and(|e| e.width == Width::Wide || e.minimum > cell_width); + let width = if full { width } else { cell_width }; + ui.allocate_ui_with_layout(vec2(width.max(0.0), t.control_height), egui::Layout::top_down(egui::Align::Min), |ui| { + ui.set_width(width.max(0.0)); + let stack = entries.get(index).is_some_and(|e| e.minimum > width); + let direction = + if stack { egui::Layout::top_down(egui::Align::Min) } else { egui::Layout::left_to_right(egui::Align::Center) }; + let response = ui.with_layout(direction, |ui| { + ui.spacing_mut().item_spacing.x = t.space_8; + let total: f32 = cell.iter().filter_map(|i| metrics.get(*i)).map(|m| m.minimum).sum(); + let usable = (width - t.space_8 * cell.len().saturating_sub(1) as f32).max(0.0); + for &i in cell { + if let (Some(option), Some(metrics)) = (options.get(i), metrics.get(i)) { + let option_width = if stack { width } else { usable * metrics.minimum / total.max(1.0) }; + ui.allocate_ui_with_layout( + vec2(option_width.max(0.0), t.control_height), + egui::Layout::left_to_right(egui::Align::Center), + |ui| { + theme_option_control(ui, option, options, values, metrics, cell.len() == 1); + }, + ); + } + } + }); + if let Some(option) = cell.first().and_then(|i| options.get(*i)) { + scenario::publish_rect(&format!("prefs:theme_option_cell:{}", option.definition.id), response.response.rect); + } + }); + } + }); + } + }; + ui.push_id(("theme_options", theme_id), |ui| { + draw_rows(ui, &plan.rows); + if plan.more_count > 0 { + let title = crate::i18n::format("prefs.theme_options_more", &[("count", &plan.more_count.to_string())]); + super::section(ui, "more", &title, false, |ui| draw_rows(ui, &plan.more)); + if let Some(rect) = scenario::rect_of(&format!("dialog:section:{title}")) { + scenario::publish_target("prefs:theme_options_more", rect); + } + } + }); + reset +} + +fn theme_option_control( + ui: &mut Ui, + option: &ThemeOptionRow<'_>, + options: &[ThemeOptionRow<'_>], + values: &mut std::collections::BTreeMap, + metrics: &ThemeOptionMetrics, + fill_range: bool, +) { + use crate::theme_files::{OptionKind, OptionValue}; + let t = Tokens::get(); + let def = option.definition; + let mut active = option.reason.is_none(); + let mut parent = def.parent.as_deref(); + for _ in 0..crate::theme_files::MAX_OPTIONS { + let Some(id) = parent else { break }; + active &= values.get(id) == Some(&OptionValue::Bool(true)); + let ancestor = options.iter().find(|o| o.definition.id == id); + active &= ancestor.is_some_and(|o| o.reason.is_none()); + parent = ancestor.and_then(|o| o.definition.parent.as_deref()); + } + let mut value = values.get(&def.id).cloned().unwrap_or_else(|| def.default_value()); + let before = value.clone(); + let control_width = (ui.available_width() - metrics.reason).max(0.0); + let response = ui.allocate_ui_with_layout(vec2(control_width, t.control_height), egui::Layout::left_to_right(egui::Align::Center), |ui| { + ui.push_id(&def.id, |ui| { + ui.add_enabled_ui(active, |ui| { + // `row` owns the accessible control label and the shared control geometry. + super::settings_form(ui, metrics.label, |ui| { + super::row(ui, &option.label, |ui| { + match (&def.kind, &mut value) { + (OptionKind::Bool { .. }, OptionValue::Bool(on)) => { + theme_option_switch(ui, &option.label, on); + } + (OptionKind::Range { min, max, step, unit, .. }, OptionValue::Num(n)) => { + let available = dimension(ui.available_width() - metrics.readout - t.space_8); + // Standalone ranges fill their cell; composites retain their compact control alignment. + let width = + if fill_range { available } else { available.min(t.form_label_width.max(theme_option_slider_minimum(&t))) }; + ui.allocate_ui_with_layout(vec2(width, t.control_height), egui::Layout::left_to_right(egui::Align::Center), |ui| { + super::slider( + ui, + n, + *min..=*max, + super::Slider { step: Some(f64::from(*step)), show_value: false, ..Default::default() }, + ); + }); + // Keep fractional theme values visible, including sliders with fractional steps. + let readout = ui.allocate_ui_with_layout( + vec2(metrics.readout, t.control_height), + egui::Layout::left_to_right(egui::Align::Center), + |ui| { + super::text(ui, &format!("{}{}", *n, unit.as_deref().unwrap_or("")), super::Text::Inline); + }, + ); + scenario::publish_rect(&format!("prefs:theme_option_readout:{}", def.id), readout.response.rect); + } + (OptionKind::Choice { values, .. }, OptionValue::Choice(selected)) => { + let labels: Vec<_> = option.choices.iter().map(String::as_str).collect(); + let mut index = values.iter().position(|v| &v.id == selected).unwrap_or(0); + let changed = if values.len() > 5 { + super::dropdown_picked(ui, &def.id, &mut index, &labels) + } else { + super::segmented(ui, &mut index, &labels, super::Segments { compact: true, ..Default::default() }) + }; + if changed && let Some(choice) = values.get(index) { + *selected = choice.id.clone(); + } + } + _ => {} + } + }) + }); + }) + }) + .inner + .response + }); + let response = response.inner.on_hover_text(&option.help); + scenario::publish_target(&format!("prefs:theme_option:{}", def.id), response.rect); + if let Some(reason) = &option.reason { + let note = ui.horizontal(|ui| super::settings_inline_note(ui, reason)); + scenario::publish_rect(&format!("prefs:theme_option_reason:{}", def.id), note.response.rect); + } + if active && value != before { + values.insert(def.id.clone(), value); + } +} + +fn theme_option_switch(ui: &mut Ui, label: &str, on: &mut bool) { + let t = Tokens::get(); + let (rect, response) = ui.allocate_exact_size(vec2(t.space_24 + t.space_8, t.control_height), Sense::click()); + if response.clicked() { + *on = !*on; + } + let track = Rect::from_center_size(rect.center(), vec2(rect.width(), t.space_16 + t.space_2)); + ui.painter().rect_filled(track, t.radius_xl, if *on { t.form_primary_fill } else { t.control_border }); + let x = if *on { track.right() - t.space_8 - t.space_2 } else { track.left() + t.space_8 + t.space_2 }; + ui.painter().circle_filled(pos2(x, track.center().y), t.space_6, if *on { t.on_accent } else { t.form_ink }); + if response.has_focus() { + ui.painter().rect_stroke(rect, t.control_radius, Stroke::new(t.space_2, t.form_primary), egui::StrokeKind::Inside); + } + response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, ui.is_enabled(), *on, label)); + scenario::publish_target(&format!("prefs:theme_option_switch:{label}"), response.rect); +} + /// Page scroller keeps content aligned while centring its bar in the window's right inset. /// Nested pages (including Shortcuts) inherit the same gutter and height contract. pub(crate) fn settings_scroll(ui: &mut Ui, id: impl egui::AsIdSalt, add: impl FnOnce(&mut Ui) -> R) -> egui::scroll_area::ScrollAreaOutput { @@ -51,6 +355,10 @@ pub(crate) fn settings_scroll(ui: &mut Ui, id: impl egui::AsIdSalt, add: impl } pub(crate) fn settings_heading(ui: &mut Ui, title: &str, description: &str) { + settings_heading_with_tag(ui, title, description, None); +} + +pub(crate) fn settings_heading_with_tag(ui: &mut Ui, title: &str, description: &str, tag: Option) { if crate::prefs_search::querying(ui) { return; } @@ -60,6 +368,12 @@ pub(crate) fn settings_heading(ui: &mut Ui, title: &str, description: &str) { let (rect, response) = ui.allocate_exact_size(vec2(dimension(ui.available_width()), t.space_16 + t.space_3), Sense::hover()); response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, ui.is_enabled(), title)); paint_text(ui, rect, title, crate::fonts::semibold(t.font_intro), t.form_ink, false); + if let Some(tag) = tag { + let width = ui.painter().layout_no_wrap(title.to_owned(), crate::fonts::semibold(t.font_intro), t.form_ink).size().x; + let start = (rect.left() + width + t.space_8).min(rect.right()); + let tag_rect = Rect::from_min_max(egui::pos2(start, rect.top()), rect.max); + ui.scope_builder(egui::UiBuilder::new().max_rect(tag_rect), tag); + } super::text(ui, description, super::Text::Hint("settings_heading")); }); } diff --git a/crates/ui-egui/src/ui_kit/preferences_layout.rs b/crates/ui-egui/src/ui_kit/preferences_layout.rs new file mode 100644 index 0000000000..f92b3c5cc9 --- /dev/null +++ b/crates/ui-egui/src/ui_kit/preferences_layout.rs @@ -0,0 +1,103 @@ +//! Pure ordered option packing. Composite descendants belong to their parent's single cell. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) enum Width { + Narrow, + Composite, + Wide, +} + +pub(crate) struct Entry { + pub width: Width, + pub advanced: bool, + /// Measured minimum row width, including translated labels and controls. + pub minimum: f32, +} + +#[derive(Debug, PartialEq)] +pub(crate) struct Plan { + pub rows: Vec>, + pub more: Vec>, + pub more_count: usize, + pub section_labels: bool, +} + +pub(crate) fn choice_width(values: usize) -> Width { + if (4..=5).contains(&values) { Width::Wide } else { Width::Narrow } +} + +/// `width` is the usable width after the caller subtracts the inter-column gutter. +pub(crate) fn layout(entries: &[Entry], width: f32) -> Plan { + let mut main = Vec::new(); + let mut more = Vec::new(); + for (i, entry) in entries.iter().enumerate() { + if entry.advanced || main.len() >= 10 { + more.push(i); + } else { + main.push(i); + } + } + let pack = |indices: &[usize]| { + let mut rows: Vec> = Vec::new(); + let mut pending = Vec::new(); + for &i in indices { + let Some(entry) = entries.get(i) else { continue }; + let wide = entry.width == Width::Wide || !width.is_finite() || entry.minimum > width.max(0.0) / 2.0; + if wide { + if !pending.is_empty() { + rows.push(std::mem::take(&mut pending)); + } + rows.push(vec![i]); + } else { + pending.push(i); + if pending.len() == 2 { + rows.push(std::mem::take(&mut pending)); + } + } + } + if !pending.is_empty() { + rows.push(pending); + } + rows + }; + Plan { rows: pack(&main), more: pack(&more), more_count: more.len(), section_labels: entries.len() > 6 } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn theme_options_layout_counts_and_order() { + for count in [0_usize, 1, 2, 3, 6, 10, 12] { + let entries: Vec<_> = (0..count).map(|_| Entry { width: Width::Narrow, advanced: false, minimum: 100.0 }).collect(); + let plan = layout(&entries, 600.0); + assert_eq!(plan.rows.iter().flatten().copied().collect::>(), (0..count.min(10)).collect::>()); + assert_eq!(plan.more.iter().flatten().copied().collect::>(), (10..count).collect::>()); + assert_eq!(plan.rows.len(), count.min(10).div_ceil(2)); + assert_eq!(plan.more_count, count.saturating_sub(10)); + assert_eq!(plan.section_labels, count > 6); + } + } + + #[test] + fn theme_options_layout_wide_composite_dropdown_advanced_and_width() { + let mut entries: Vec<_> = (0..13).map(|_| Entry { width: Width::Narrow, advanced: false, minimum: 100.0 }).collect(); + entries[1].width = Width::Wide; + entries[2].width = Width::Composite; + entries[3].advanced = true; + let plan = layout(&entries, 600.0); + assert_eq!(&plan.rows[..3], &[vec![0], vec![1], vec![2, 4]]); + assert_eq!(plan.rows.iter().flatten().count(), 10, "advanced does not consume the ten offered cells"); + assert_eq!(plan.more.iter().flatten().copied().collect::>(), [3, 11, 12]); + assert_eq!(choice_width(3), Width::Narrow); + assert_eq!(choice_width(4), Width::Wide); + assert_eq!(choice_width(5), Width::Wide); + assert_eq!(choice_width(6), Width::Narrow); + let advanced_only = [Entry { width: Width::Composite, advanced: true, minimum: 100.0 }]; + assert!(layout(&advanced_only, 600.0).rows.is_empty()); + assert_eq!(layout(&advanced_only, 600.0).more, [vec![0]], "advanced stays in More even below ten entries"); + entries[0].minimum = 350.0; + assert_eq!(layout(&entries, 600.0).rows[0], [0]); + assert!(layout(&entries, 150.0).rows.iter().all(|row| row.len() == 1)); + } +} diff --git a/crates/ui-egui/src/ui_kit/tree.rs b/crates/ui-egui/src/ui_kit/tree.rs index f89b2061bb..76af5eee42 100644 --- a/crates/ui-egui/src/ui_kit/tree.rs +++ b/crates/ui-egui/src/ui_kit/tree.rs @@ -5,6 +5,10 @@ use egui::{Color32, FontId, Pos2, Rect, Sense, Stroke, pos2, vec2}; const ROW_H: f32 = super::TREE_ROW_HEIGHT; const INDENT: f32 = 14.0; const DIM_ICON_OPACITY: f32 = 0.4; +/// A drag over the eyes scrolls the rows when the pointer is this near their top or bottom edge, +/// by this many points a frame (#9680). +const DRAG_EDGE: f32 = 24.0; +const DRAG_SCROLL: f32 = 6.0; /// Trailing tree action; disabled actions retain their reason and a scenario rectangle. pub(crate) struct TreeAction<'a> { @@ -99,6 +103,7 @@ pub(crate) struct RowResp { pub(crate) double: bool, pub(crate) secondary: Option, pub(crate) fold: bool, + /// The eye was clicked, or a drag over the eyes sets it (#9680, see [`eye_drag_id`]). pub(crate) eye: bool, pub(crate) eye_rect: Option, pub(crate) radio: bool, @@ -118,6 +123,12 @@ pub(crate) struct RowResp { pub(crate) actions: Vec, } +/// The state a drag over the eyes sets them to: the first eye's new state, while the button is +/// down (egui temporary data). Absent when no eye drag is under way. +pub(crate) fn eye_drag_id() -> egui::Id { + egui::Id::new("sc_tree_eye_drag") +} + /// Geometry used by Browser culling and the shared row painter. pub(crate) fn tree_row_rect(ui: &egui::Ui, row: &Row<'_>) -> Rect { let t = Tokens::get(); @@ -214,7 +225,8 @@ pub(crate) fn tree_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp { let radio_x = x + 14.0 + slot + 6.0; let radio_r = Rect::from_center_size(pos2(radio_x, cy), vec2(t.space_12 + t.space_3, r.height())); // The button right after the name takes room from the label; it is placed once the label is laid out. - let name_action_width = if row.action.is_some() { 22.0 } else { 0.0 }; + let (name_action_gap, name_action_size) = (t.space_8, t.space_16 + t.space_4); + let name_action_width = if row.action.is_some() { name_action_gap + name_action_size } else { 0.0 }; let at = |p: Pos2| -> u8 { if !row.actions.is_empty() && p.x >= action_left { 4 @@ -246,6 +258,26 @@ pub(crate) fn tree_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp { // Hidden or faded rows keep main's dimming, with the rounded suppression asset. icons::paint_icon(ui.painter(), er, if vis { "eye" } else { "suppress" }, if vis && !row.dim { 0.85 } else { DIM_ICON_OPACITY }); out.eye = resp.clicked() && click_on == 2; + // A press on an eye that turns into a drag takes the opposite state, and each eye the + // pointer then passes over takes that same state (#9680). + if resp.drag_started() && press_on == 2 { + ui.data_mut(|d| d.insert_temp(eye_drag_id(), !vis)); + out.eye = true; + } else if let Some(p) = ui.input(|i| i.pointer.primary_down().then(|| i.pointer.hover_pos()).flatten()) + && at(p) == 2 + && ui.rect_contains_pointer(eye_r) + && let Some(target) = ui.data(|d| d.get_temp::(eye_drag_id())) + { + out.eye |= target != vis; + // Within a few points of the rows' top or bottom edge, the drag scrolls them. + let clip = ui.clip_rect(); + if p.y < clip.top() + DRAG_EDGE { + ui.scroll_to_rect(Rect::from_min_size(pos2(r.left(), clip.top() - DRAG_SCROLL), vec2(1.0, 1.0)), None); + } else if p.y > clip.bottom() - DRAG_EDGE { + ui.scroll_to_rect(Rect::from_min_size(pos2(r.left(), clip.bottom() + DRAG_SCROLL), vec2(1.0, 1.0)), None); + } + ui.ctx().request_repaint(); + } out.eye_rect = Some(eye_r.intersect(ui.clip_rect())); out.eye_at = Some(er.center()); crate::scenario::publish_handle(&format!("eye:{}", row.label), er.center()); @@ -349,7 +381,10 @@ pub(crate) fn tree_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp { bx += 15.0; } // The name's action button sits right after the label; a press on it is not a press on the row. - let action_r = Rect::from_min_size(pos2(x + lw + 2.0, r.top()), vec2(t.space_16 + t.space_4, r.height())); + // The gap before it belongs to the row: a click that lands just past the end of the name, or + // a press on the name that slips a few points before its release, selects the row instead of + // opening the feature's sketch (#10079). The gap is wider than egui's click slop (6 points). + let action_r = Rect::from_min_size(pos2(x + lw + name_action_gap, r.top()), vec2(name_action_size, r.height())); let over_action = |p: Option| row.action.is_some() && p.is_some_and(|p| action_r.contains(p)); let action_hover = over_action(resp.hover_pos()); let action_click = over_action(resp.interact_pointer_pos()); diff --git a/crates/ui-egui/src/ui_kit/value_focus.rs b/crates/ui-egui/src/ui_kit/value_focus.rs index 684917bf27..f47f569789 100644 --- a/crates/ui-egui/src/ui_kit/value_focus.rs +++ b/crates/ui-egui/src/ui_kit/value_focus.rs @@ -15,6 +15,13 @@ pub(crate) fn last_value_field(ctx: &egui::Context) -> Option { ctx.data(|data| data.get_temp::>(egui::Id::new("sc_value_fields"))).and_then(|fields| fields.last().map(|(id, _)| *id)) } +/// True while a text editor drawn by the kit holds the keyboard. A dialog uses this to keep at +/// most one field looking active: its pick fields drop their accent ring while a text field +/// has the keyboard (#10066). +pub(crate) fn editor_has_focus(ctx: &egui::Context) -> bool { + ctx.data(|data| data.get_temp::(egui::Id::new("sc_focused_editor"))).is_some_and(|id| ctx.memory(|memory| memory.focused() == Some(id))) +} + /// Only a value box drawn this pass can receive the keyboard after a viewport pick. pub(crate) fn canvas_value_focus(ctx: &egui::Context) -> Option<(egui::Id, usize)> { ctx.data(|data| data.get_temp::<(u64, egui::Id, usize)>(egui::Id::new("sc_canvas_value_focus"))) diff --git a/crates/ui-egui/src/ui_kit/viewport_status.rs b/crates/ui-egui/src/ui_kit/viewport_status.rs index 1788fb6cca..90e7db1875 100644 --- a/crates/ui-egui/src/ui_kit/viewport_status.rs +++ b/crates/ui-egui/src/ui_kit/viewport_status.rs @@ -35,15 +35,52 @@ pub(crate) struct StatusItem { #[derive(Default)] pub(crate) struct StatusPresentation { pub open_key: Option, + pub focus_field: Option, + pub selected_record: Option, + pub live_details: bool, + return_focus: Option, + return_pending: bool, + card_owner: Option, + opened_frame: Option, + session_uid: Option, focus_ids: Vec, summary_popup: Option, summary_members: Vec, + summary_suspended: bool, } impl StatusPresentation { + /// A card and its focus/popup identities never follow a different document's equal slot ID. + pub(crate) fn reconcile_session(&mut self, ctx: &egui::Context, uid: u64) { + if self.session_uid.is_some_and(|previous| previous != uid) { + super::dismiss_details_popups(ctx); + if let Some(popup) = self.summary_popup { + egui::Popup::close_id(ctx, popup); + } + if self.owns_focus(ctx) || super::details_field_owns_focus(ctx) { + ctx.memory_mut(|memory| { + if let Some(id) = memory.focused() { + memory.surrender_focus(id); + } + }); + } + *self = Self::default(); + } + self.session_uid = Some(uid); + } /// Only popup/focus identities are retained; every displayed value comes from this frame. pub(crate) fn reconcile_summary(&mut self, ctx: &egui::Context, members: Vec) { if self.summary_members != members { + super::dismiss_details_popups(ctx); + if self.open_key.is_some() && (super::details_field_owns_focus(ctx) || super::details_control_owns_focus(ctx)) { + ctx.memory_mut(|memory| { + if let Some(id) = memory.focused() { + memory.surrender_focus(id); + } + }); + } + self.return_pending = false; + self.card_owner = None; if let Some(id) = self.summary_popup.take() { egui::Popup::close_id(ctx, id); } @@ -56,12 +93,53 @@ impl StatusPresentation { } self.focus_ids.clear(); self.open_key = None; + self.focus_field = None; self.summary_members = members; } } + /// A higher owner dismisses only this popup, without returning focus to its badge. + pub(crate) fn suspend_summary(&mut self, ctx: &egui::Context, suspended: bool) { + if suspended { + if self.live_details { + super::dismiss_details_popups(ctx); + } + if self.open_key.is_some() && (super::details_field_owns_focus(ctx) || super::details_control_owns_focus(ctx)) { + ctx.memory_mut(|memory| { + if let Some(id) = memory.focused() { + memory.surrender_focus(id); + } + }); + } + self.focus_field = None; + self.return_pending = false; + } + self.summary_suspended = suspended; + if suspended && !self.summary_members.is_empty() { + if let Some(id) = self.summary_popup.take() { + egui::Popup::close_id(ctx, id); + } + if self.owns_focus(ctx) { + ctx.memory_mut(|memory| { + if let Some(id) = memory.focused() { + memory.surrender_focus(id); + } + }); + } + self.focus_ids.clear(); + } + } + pub(crate) fn owns_focus(&self, ctx: &egui::Context) -> bool { ctx.memory(|memory| memory.focused().is_some_and(|id| self.focus_ids.contains(&id))) + || (self.open_key.is_some() && super::details_control_owns_focus(ctx)) + } + + pub(crate) fn close_details(&mut self) { + self.open_key = None; + self.focus_field = None; + self.return_pending = true; + self.card_owner = None; } } @@ -150,6 +228,13 @@ pub(crate) fn viewport_status_item(ui: &mut Ui, item: &StatusItem, state: &mut S let label = crate::i18n::format("ui.viewport_status_key", &[("name", &item.name)]); let response = icon_action(ui, item, "key", "chevron", &label, &label, open); state.focus_ids.push(response.id); + if open || response.clicked() || matches!(&action, Some(StatusAction::Edit(id)) if id == &item.id) { + state.return_focus = Some(response.id); + } + if state.return_pending && state.return_focus == Some(response.id) && !egui::Popup::is_any_open(ui.ctx()) { + response.request_focus(); + state.return_pending = false; + } if response.clicked() { state.open_key = if open { None } else { Some(item.id.clone()) }; } @@ -215,18 +300,15 @@ pub(crate) fn viewport_status_row( }); scenario::publish_rect("viewport_status:row", row.response.rect); scenario::publish_count("viewport_status:count", items.len() as f64); + if state.return_pending && state.return_focus.is_none_or(|id| !state.focus_ids.contains(&id)) { + state.return_pending = false; + state.return_focus = None; + } if let Some((anchor, id)) = key_anchor { - let card = Container::new(ctx, Surface::Popup); - // The key belongs to viewport chrome, not to the system-window/popup layer. - let result = - card.area(Id::new("viewport_status_key")).order(egui::Order::Background).movable(false).fixed_pos(anchor + vec2(0.0, t.space_6)).show( - ctx, - |ui| { - let frame = card.frame(ctx, t.overlay, crate::frame::Side::Left).inner_margin(t.space_12); - card.show_frame(ui, frame, free, |ui| key_contents(ui, id)); - }, - ); - scenario::publish_target("viewport_status:card", result.response.rect); + show_card(ctx, free, anchor, row.response.rect, id, state, &mut key_contents); + } else { + state.card_owner = None; + state.focus_field = None; } actions } @@ -262,6 +344,10 @@ pub(crate) fn viewport_status_summary( }; let frame = container.frame(ctx, t.overlay, crate::frame::Side::Left).stroke(t.primary_border(outline)).inner_margin(t.space_4); container.show_frame(ui, frame, free, |ui| { + // The background badge cannot open a foreground popup above a higher owner. + if state.summary_suspended { + ui.disable(); + } let mut button_id = None; let mut summary_popup = None; let menu = super::Menu::button_with( @@ -379,6 +465,13 @@ pub(crate) fn viewport_status_summary( ); state.summary_popup = summary_popup; state.focus_ids.extend(button_id); + if state.open_key.is_some() { + state.return_focus = button_id; + } + if state.return_pending && state.return_focus == button_id && !egui::Popup::is_any_open(ctx) { + menu.response.request_focus(); + state.return_pending = false; + } if was_open && !summary_popup.is_some_and(|id| egui::Popup::is_id_open(ctx, id)) && actions.is_empty() { menu.response.request_focus(); } @@ -389,42 +482,51 @@ pub(crate) fn viewport_status_summary( }); scenario::publish_rect("viewport_status:row", row.response.rect); scenario::publish_count("viewport_status:count", items.len() as f64); - if let Some(id) = state.open_key.as_deref() { - let card = Container::new(ctx, Surface::Popup); - let result = card - .area(Id::new("viewport_status_key")) - .order(egui::Order::Background) - .movable(false) - .fixed_pos(row.response.rect.left_bottom() + vec2(0.0, t.space_6)) - .show(ctx, |ui| { - let frame = card.frame(ctx, t.overlay, crate::frame::Side::Left).inner_margin(t.space_12); - card.show_frame(ui, frame, free, |ui| key_contents(ui, id)); - }); - scenario::publish_target("viewport_status:card", result.response.rect); + if let Some(id) = state.open_key.clone() { + show_card(ctx, free, row.response.rect.left_bottom(), row.response.rect, &id, state, &mut key_contents); + } else { + state.card_owner = None; + state.focus_field = None; } actions } -/// Shared legend content; the caller supplies the existing analysis-token swatches and text. -pub(crate) fn analysis_key(ui: &mut Ui, title: &str, along: Option<&str>, rows: &[(egui::Color32, String, bool)]) { - let t = Tokens::get(); - ui.label(RichText::new(title).semibold().size(t.font_label).color(t.text)); - if let Some(along) = along { - ui.label(RichText::new(along).monospace().size(t.font_small).color(t.text_dim)); +fn show_card( + ctx: &egui::Context, + free: Rect, + anchor: egui::Pos2, + owner: Rect, + id: &str, + state: &mut StatusPresentation, + contents: &mut impl FnMut(&mut Ui, &str), +) { + if state.live_details && state.summary_suspended { + return; } - for (colour, label, hatched) in rows { - ui.horizontal(|ui| { - let (rect, _) = ui.allocate_exact_size(vec2(t.space_12, t.space_12), egui::Sense::hover()); - ui.painter().rect_filled(rect, t.radius_sm, *colour); - if *hatched { - let painter = ui.painter().with_clip_rect(rect); - for offset in [-t.space_8, -t.space_4, 0.0, t.space_4, t.space_8] { - let from = rect.left_bottom() + vec2(offset, 0.0); - painter - .line_segment([from, from + vec2(rect.height(), -rect.height())], egui::Stroke::new(t.primary_border(t.text).width, t.text)); - } - } - ui.label(RichText::new(label).size(t.font_label).color(t.text)); - }); + if state.card_owner.as_deref() != Some(id) { + state.card_owner = Some(id.to_owned()); + state.opened_frame = Some(ctx.cumulative_frame_nr()); + } + let popup_was_open = egui::Popup::is_any_open(ctx); + let t = Tokens::get(); + let card = Container::new(ctx, Surface::Popup); + let result = + card.area(Id::new("viewport_status_key")).order(egui::Order::Background).movable(false).fixed_pos(anchor + vec2(0.0, t.space_6)).show( + ctx, + |ui| { + let frame = card.frame(ctx, t.overlay, crate::frame::Side::Left).inner_margin(t.space_12); + card.show_frame(ui, frame, free, |ui| contents(ui, id)); + }, + ); + scenario::publish_target("viewport_status:card", result.response.rect); + if state.opened_frame != Some(ctx.cumulative_frame_nr()) + && !popup_was_open + && !egui::Popup::is_any_open(ctx) + && ctx.input(|input| { + input.pointer.any_pressed() + && input.pointer.interact_pos().is_some_and(|point| !result.response.rect.contains(point) && !owner.contains(point)) + }) + { + state.close_details(); } } diff --git a/crates/ui-egui/src/view_confirm.rs b/crates/ui-egui/src/view_confirm.rs index 38f8e7042e..32c73b7040 100644 --- a/crates/ui-egui/src/view_confirm.rs +++ b/crates/ui-egui/src/view_confirm.rs @@ -1,7 +1,8 @@ //! OK and Cancel in the view's corner (Preferences › Interface, #6709). While a dialog is open, //! a check and a cross at the top right of the view do what the dialog's own OK and Cancel do, //! and the check greys out whenever the dialog's OK does. While a sketch is open without a -//! dialog, they are Finish Sketch and Cancel Sketch, as on the ribbon. The axis view moves down +//! dialog, they are Finish Sketch and Cancel Sketch, as on the ribbon, except that while a tool +//! is out the cross first cancels it, as one Esc does. The axis view moves down //! below the pair while it is shown (`viewport_layout::Layout::with_content`), and the pair is //! one of the shell's covered rects, so a click on it never picks or navigates. @@ -80,7 +81,10 @@ pub(crate) fn show(app: &mut SolveApp, ctx: &egui::Context) { Answers::Sketch => { let command = |id: &str| solidcraft_engine::find_command(id).map(|spec| (spec.label, spec.info(&app.session).enabled)); let (finish, finish_enabled) = command("sketch.finish").unwrap_or(("Finish Sketch", false)); - let (cancel, cancel_enabled) = command("sketch.cancel").unwrap_or(("Cancel Sketch", false)); + // While a tool or a drag is unfinished, the cross cancels that first, as one Esc does; + // the next click cancels the sketch. + let (cancel, cancel_enabled) = + if crate::toolbar::unfinished(app) { ("Cancel", true) } else { command("sketch.cancel").unwrap_or(("Cancel Sketch", false)) }; [ Choice { action: Action::Ok, key: "view:confirm:ok", icon: "finish", label: finish, enabled: finish_enabled }, Choice { action: Action::Cancel, key: "view:confirm:cancel", icon: "sketch_cancel", label: cancel, enabled: cancel_enabled }, @@ -116,7 +120,8 @@ pub(crate) fn show(app: &mut SolveApp, ctx: &egui::Context) { match (answers, clicked) { (Answers::Dialog, Some(action)) => app.viewport.corner = Some(action), (Answers::Sketch, Some(Action::Ok)) => app.start_pinned("sketch.finish", None), - (Answers::Sketch, Some(Action::Cancel)) => app.start_pinned("sketch.cancel", None), + // The guard cancels an unfinished tool first; only with nothing to cancel does the sketch go. + (Answers::Sketch, Some(Action::Cancel)) if !crate::toolbar::cancel_unfinished(app) => app.start_pinned("sketch.cancel", None), _ => {} } } diff --git a/crates/ui-egui/src/view_home_tests.rs b/crates/ui-egui/src/view_home_tests.rs index b01c66a2bf..35d3f75b26 100644 --- a/crates/ui-egui/src/view_home_tests.rs +++ b/crates/ui-egui/src/view_home_tests.rs @@ -278,6 +278,36 @@ fn fit_and_home_json_commands_preserve_design_framing() { assert_eq!(h.app.session.undo.len(), undo); } +/// #10091: Preferences › Navigation "Home and Fit size" is how much of the view the model fills +/// when Home and Fit (and an opened design or a navigator view change) frame it; 100 %, the +/// default, fills the room between the panels and the controls as before. +#[test] +fn home_fit_size_scales_the_framed_model() { + let mut h = Harness::new(); + h.app.preferences.look_at_anim = false; + moved_block(&mut h); + assert_eq!(h.app.preferences.home_fit_size, 1.0, "fills the room by default"); + h.call("engine.execute", json!({"command":"view.home","params":{}})); + h.frames(2); + let tight = h.app.cam; + h.call("ui.prefs", json!({"home_fit_size":0.8})); + for command in ["view.home", "view.fit"] { + h.call("engine.execute", json!({"command":command,"params":{}})); + h.frames(2); + assert!((h.app.cam.distance / tight.distance - 1.25).abs() < 0.02, "{command}: {} vs {}", h.app.cam.distance, tight.distance); + assert_eq!((h.app.cam.yaw, h.app.cam.pitch), (tight.yaw, tight.pitch), "{command}: the size only zooms"); + } + for bad in [1.5, 0.05, 0.0, -1.0] { + let r = h.call("ui.prefs", json!({"home_fit_size":bad})); + assert_eq!(r["ok"], false, "{bad} is outside 10 %…100 %: {r}"); + } + assert!((h.app.preferences.home_fit_size - 0.8).abs() < 1e-6); + h.call("ui.prefs", json!({"open":"Navigation"})); + h.call("ui.prefs", json!({"button":"defaults"})); + assert_eq!(h.app.preferences.home_fit_size, 1.0, "Navigation defaults restore 100 %"); + h.call("ui.prefs", json!({"button":"cancel"})); +} + #[test] fn fit_json_frames_selection_while_home_frames_everything() { let mut h = Harness::new(); diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 99940cf2f5..cf2fbd6616 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -8,7 +8,7 @@ use solidcraft_engine::Sel; use solidcraft_engine::geom::{Plane, Vec2, Vec3}; use solidcraft_engine::render::{Camera, Mat4, Rgb}; use solidcraft_engine::sketch::CurveKind; -use solidcraft_engine::view::{GridFade, GridPlane, colors, grid_ext, grid_plane, grid_step, sketch_lines_with_sampler}; +use solidcraft_engine::view::{GridFade, GridPlane, colors, grid_ext, grid_lines, grid_plane, grid_step, sketch_lines_with_sampler}; use std::hash::{Hash, Hasher}; use crate::gpu::{Frame, GpuScene, GpuTarget, SceneSlot, ViewportCallback}; @@ -178,6 +178,9 @@ pub struct ViewportState { pub orbit_from: Option, /// Continuous orbit: the view turns about the vertical until the next click or key. pub spin: bool, + /// The point continuous orbit turns about (Orbit around: origin or the cursor's model point), + /// chosen when it starts and kept until it stops, so the model stays in view (#1044). + pub spin_pivot: Option, /// The 3D mouse's motion this frame, for the page showing the camera to apply with what its /// camera may do (`navigation::device`). pub device_motion: Option, @@ -583,7 +586,7 @@ fn scene_key(app: &SolveApp) -> u64 { crate::theme::is_dark().hash(&mut h); crate::theme::vision().hash(&mut h); // Two themes may share a light/dark base while drawing different geometry colours. - crate::theme_files::color_key(&Tokens::get()).hash(&mut h); + crate::theme_files::color_key(&Tokens::base()).hash(&mut h); app.session.active_sketch.hash(&mut h); crate::edit_in_place::scene_key(app).hash(&mut h); // A reset palette arena (another design) invalidates every cached block's indices. @@ -670,6 +673,18 @@ fn supersampling(app: &mut SolveApp, ctx: &egui::Context, view_proj: [[f32; 4]; /// Ambient occlusion samples per pixel this frame (#7165): the full count once the view has held /// still for a moment, fewer while it moves, so orbiting with AO on stays quick. +/// The shader's sky (#7974): the world's up direction, and 1 to shade in linear light with a sky +/// fill (Preferences › Graphics), else zero. +pub(crate) fn sky_uniform(on: bool, up: solidcraft_engine::render::UpAxis) -> [f32; 4] { + if !on { + return [0.0; 4]; + } + match up { + solidcraft_engine::render::UpAxis::Z => [0.0, 0.0, 1.0, 1.0], + solidcraft_engine::render::UpAxis::Y => [0.0, 1.0, 0.0, 1.0], + } +} + fn ao_samples(app: &mut SolveApp, ctx: &egui::Context, view_proj: [[f32; 4]; 4], size_px: [f32; 2]) -> u32 { // Without AO (off, or wireframe) the count is unused: no repaint is asked for it. if !app.preferences.ambient_occlusion || app.ui.visual_style == 2 || view_settled(app, ctx, view_proj, size_px) { @@ -1638,7 +1653,10 @@ pub(crate) fn build_scene(app: &SolveApp) -> GpuScene { } // Continuity analysis: each seam `inspect.continuity` measured, drawn over the model in the // colour of its worst verdict. - for seam in &s.continuity { + for (seam, detail) in s.continuity.iter().zip(s.continuity_details()) { + if !detail.current { + continue; + } let col = sc.color(c4(continuity_color(&tk, seam.verdict))); for w in seam.points.windows(2) { if let (Some(a), Some(b)) = (w.first(), w.get(1)) { @@ -1676,7 +1694,7 @@ fn build_sketch_scene(app: &SolveApp, proj: &Proj) -> GpuScene { // An open sketch has its grid whatever the Grid switch says, unless the sketch's own Sketch // Grid option (Sketch panel) is off (#9147); outside a sketch that option is never off. if (sketch_plane.is_some() || app.ui.show_grid) && !active_view(app).hide_grid { - let (minor, major) = grid_step(app.cam.half_height()); + let major = grid_step(app.cam.half_height()).1; // An open sketch has its grid on its own plane, and so does the plane Create Sketch // would take; otherwise the ground plane: XY with Z up, XZ with Y up (laid out in the // camera's Z-up frame), or the upright origin plane facing the view when the ground is @@ -1725,17 +1743,22 @@ fn build_sketch_scene(app: &SolveApp, proj: &Proj) -> GpuScene { // now drawn in pieces, each as faint as the fade makes it there, and the grid is gone // before its own edge, which is therefore never seen. let fade = GridFade::new(app.cam.half_height(), slant, ext, far_reach); - let n = ((fade.end / minor) as i64).clamp(1, 300); let (grid, grid_major) = (c4(tk.grid), c4(tk.grid_major)); - for i in -n..=n { - let t = i as f64 * minor; - let maj = (t / major).round() * major == t; - let col = if maj { grid_major } else { grid }; - let wd = if maj { 1.0 } else { 0.7 }; - // Two lines through the point `c + u * t`: one along v, one along u. - for (along, across) in [(v, u), (u, v)] { + // Near a switch of the spacing the outgoing and incoming levels cross-fade (#9570), each + // line on its own place on the plane: the grid's centre on each axis, from the plane's + // origin (the sketch plane's, or the camera frame's). + let origin = sketch_plane.as_ref().map_or(Vec3::ZERO, |pl| pl.origin); + let (at_u, at_v) = ((c - origin).dot(u), (c - origin).dot(v)); + // Two lines through the point `c + u * t`: one along v, one along u. + for (along, across, at) in [(v, u, at_u), (u, v, at_v)] { + for line in grid_lines(app.cam.half_height(), at, fade.end, 300) { + let t = line.offset; + // A line two levels share with different kinds blends between the minor and + // the major look. + let col = grid_blend(grid, grid_major, line.major); + let wd = 0.7 + 0.3 * line.major; for (s0, s1, alpha) in fade.pieces(t) { - let col = sc.color(grid_colour(col, alpha)); + let col = sc.color(grid_colour(col, alpha * line.weight)); sc.under_line(w3(c + across * t + along * s0), w3(c + across * t + along * s1), col, wd); } } @@ -2788,12 +2811,63 @@ fn feature_parts(app: &SolveApp, id: u64, shown_only: bool) -> Vec { let names = solidcraft_engine::doc::naming::face_names(b); let m = b.mesh(); for (index, _) in names.iter().enumerate().filter(|(_, n)| solidcraft_engine::doc::persistent_name::made_by(n) == Some(id)) { - if let Some(point) = m.triangles.iter().zip(&m.tri_face).find_map(|(t, f)| { - if *f as usize != index { - return None; + if let Some(point) = first_triangle_point(&m, index) { + out.push(Sel::Face { body: b.name.clone(), index, point }); + } + } + } + if out.is_empty() { + // A later edit renamed the faces this feature made (an Offset Face, Replace Face or + // Delete Face leaves them named after whatever last touched them): light the faces it + // changed, found by comparing the states either side of it. + out = changed_faces(app, id, &st, shown_only); + } + out +} + +/// A point on face `index` of `m`: the middle of its first triangle. +fn first_triangle_point(m: &solidcraft_engine::geom::Mesh, index: usize) -> Option { + m.triangles.iter().zip(&m.tri_face).find_map(|(t, f)| (*f as usize == index).then(|| m.tri(t)).flatten().map(|[a, b, c]| (a + b + c) / 3.0)) +} + +/// The faces a feature changed, for the states either side of it, on the bodies as they are now: +/// a face whose name is new, or whose area, centroid or plane moved. A face a later feature +/// renamed is found again by its plane and nearest centre. +fn changed_faces(app: &SolveApp, id: u64, now: &solidcraft_engine::doc::ModelState, shown_only: bool) -> Vec { + let Some(index) = app.session.doc.features.iter().position(|f| f.id == id) else { return Vec::new() }; + let after = match app.session.doc.features.get(index + 1) { + Some(next) => app.session.model.state_before(next.id), + None => app.session.model.state(), + }; + let before = app.session.model.state_before(id); + let mut out = Vec::new(); + for b in &after.bodies { + if shown_only && (!app.session.body_visible(&b.name) || app.preview.effective_replaced().contains(&b.name)) { + continue; + } + let (Some(now_body), Some(old)) = (now.body(&b.name), before.body(&b.name)) else { continue }; + let tol = (b.body.size() * 1e-3).max(1e-4); + let (Ok(new_faces), Ok(old_faces)) = (b.body.faces(tol), old.body.faces(tol)) else { continue }; + let names = solidcraft_engine::doc::naming::face_names(b); + let old_names = solidcraft_engine::doc::naming::face_names(old); + for (i, name) in names.iter().enumerate() { + let Some(new) = new_faces.get(i) else { continue }; + // A face the feature moved takes a new name (Offset Face); a face it only trimmed + // or stretched keeps its own. Either is a face the feature changed. + let moved = match old_names.iter().position(|old| old == name) { + None => true, + Some(j) => { + let Some(was) = old_faces.get(j) else { continue }; + (new.area - was.area).abs() > new.area.max(1e-9) * 1e-4 + || new.centroid.dist(was.centroid) > tol + || matches!((new.plane_normal, was.plane_normal), (Some(a), Some(b)) if a.dot(b) < 1.0 - 1e-7) } - m.tri(t).map(|[a, b, c]| (a + b + c) / 3.0) - }) { + }; + if !moved { + continue; + } + let Some((index, point)) = current_face(now_body, name, new, tol) else { continue }; + if !out.iter().any(|s| matches!(s, Sel::Face { body, index: i, .. } if body == &b.name && *i == index)) { out.push(Sel::Face { body: b.name.clone(), index, point }); } } @@ -2801,6 +2875,34 @@ fn feature_parts(app: &SolveApp, id: u64, shown_only: bool) -> Vec { out } +/// Face `name` of `now` (or the nearest face on its plane when a later feature renamed it), +/// with a point on it. +fn current_face( + now: &solidcraft_engine::doc::ModelBody, + name: &str, + sample: &solidcraft_engine::kernel::FaceInfo, + tol: f64, +) -> Option<(usize, solidcraft_engine::geom::Vec3)> { + let m = now.mesh(); + let names = solidcraft_engine::doc::naming::face_names(now); + if let Some(index) = names.iter().position(|n| n == name) { + return first_triangle_point(&m, index).map(|point| (index, point)); + } + let normal = sample.plane_normal?; + let faces = now.body.faces(tol).ok()?; + // The same face, moved on by later edits: the nearest centre on a parallel plane, a face + // still on the plane it was on winning over one that only faces the same way. + let mut best: Option<(f64, f64, usize)> = None; + for f in faces.iter().filter(|f| f.plane_normal.is_some_and(|n| n.dot(normal) > 1.0 - 1e-7)) { + let (off, d) = ((f.centroid - sample.centroid).dot(normal).abs(), f.centroid.dist(sample.centroid)); + if best.is_none_or(|b| off.total_cmp(&b.0).then(d.total_cmp(&b.1)).is_lt()) { + best = Some((off, d, f.index)); + } + } + let index = best?.2; + first_triangle_point(&m, index).map(|point| (index, point)) +} + /// The sketches picked among `items` (a sketch row in the Browser or the timeline) that the /// highlight draws, shown or hidden (#7156). The sketch being edited shows its own selection. fn selected_sketches(app: &SolveApp, items: &[Sel]) -> Vec { @@ -2967,41 +3069,6 @@ pub(crate) fn analysis_legend_data(app: &SolveApp) -> Option { Some((title, along, rows)) } -fn analysis_legend(app: &SolveApp, painter: &egui::Painter, rect: Rect) { - let Some((title, along, rows)) = analysis_legend_data(app) else { return }; - let t = Tokens::get(); - let (pad, row) = (8.0, 18.0); - let h = pad * 2.0 + 18.0 + along.as_ref().map_or(0.0, |_| row) + row * rows.len() as f32; - // Clear Browser and a left History card so the legend is not painted under them (#4515). - let free = crate::shell::workspace_content(app, rect); - let width = 230.0_f32.min((free.width() - 24.0).max(120.0)); - let r = Rect::from_min_size(pos2(free.left() + 12.0, free.bottom() - 12.0 - h), vec2(width, h)); - painter.rect(r, t.radius_md, t.overlay, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); - crate::scenario::publish_target("analysis:legend", r); - let mut y = r.top() + pad; - painter.text(pos2(r.left() + pad, y), Align2::LEFT_TOP, title, FontId::proportional(t.font_button), t.text); - y += 18.0; - if let Some(along) = along { - painter.text(pos2(r.left() + pad, y), Align2::LEFT_TOP, along, FontId::proportional(t.font_caption), t.text_dim); - y += row; - } - for (c, text, hatched) in rows { - let sw = Rect::from_min_size(pos2(r.left() + pad, y + 2.0), vec2(12.0, 12.0)); - painter.rect_filled(sw, t.radius_sm / 2, c); - if hatched { - // The stripes the viewport adds to these faces (colour vision modes). - for k in [3.0, 7.0, 11.0] { - painter.line_segment( - [pos2(sw.left() + k - 3.0, sw.bottom()), pos2(sw.left() + k + 1.0, sw.top())], - Stroke::new(1.2, Color32::from_black_alpha(110)), - ); - } - } - painter.text(pos2(sw.right() + 8.0, y), Align2::LEFT_TOP, text, FontId::proportional(t.font_body), t.text); - y += row; - } -} - fn section_clip(app: &SolveApp) -> [f32; 4] { match section_plane(app) { Some((o, n)) => [n.x as f32, n.y as f32, n.z as f32, n.dot(o) as f32], @@ -3406,7 +3473,7 @@ fn highlight_geometry_key(app: &SolveApp) -> u64 { crate::theme::is_dark().hash(&mut h); crate::theme::vision().hash(&mut h); // Two themes may share a light/dark base while drawing different geometry colours. - crate::theme_files::color_key(&Tokens::get()).hash(&mut h); + crate::theme_files::color_key(&Tokens::base()).hash(&mut h); app.preview.effective_replaced().hash(&mut h); app.preview.rolled_back.hash(&mut h); // Where a Move dialog has moved its bodies; while its triad is dragged, only that it is. @@ -4262,16 +4329,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { let held = crate::select_other::track_hold(app, ui.ctx(), &proj, inside, primary_pressed, primary_down, hover, navigating_from_press); crate::select_other::hover_override(app); let painter = ui.painter_at(rect); - // Background gradient. - let (top, bot) = (t.viewport_top, t.viewport_bottom); - let mut mesh = egui::Mesh::default(); - mesh.colored_vertex(rect.left_top(), top); - mesh.colored_vertex(rect.right_top(), top); - mesh.colored_vertex(rect.right_bottom(), bot); - mesh.colored_vertex(rect.left_bottom(), bot); - mesh.add_triangle(0, 1, 2); - mesh.add_triangle(0, 2, 3); - painter.add(Shape::mesh(mesh)); + crate::gpu::paint_background(&painter, rect, &t); // ---- model ---- crate::frame_watch::step(app, "viewport before the model"); @@ -4360,6 +4418,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { let c = t.body_selected; [f32::from(c.r()) / 255.0, f32::from(c.g()) / 255.0, f32::from(c.b()) / 255.0] }, + sky: sky_uniform(app.preferences.dark_parts_shading, proj.cam.up), }); painter.add(egui_wgpu::Callback::new_paint_callback(rect, cb)); // The graphics card had no memory for the view's frame (#7296): say once why it looks @@ -4370,9 +4429,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { } } let overlay_rect = crate::shell::bottom_overlay_rect(app, rect); - if !app.preferences.viewport_analysis_status { - analysis_legend(app, &painter, overlay_rect); - } + crate::viewport_details::legacy_legends(app, ui, overlay_rect); crate::frame_rate::paint(app, ui, &painter, overlay_rect); // ---- interaction ---- @@ -4447,6 +4504,21 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { let held_mods = crate::tool_modifiers::Held::from_modifiers(mods); let (add, allow_toggle) = crate::tool_modifiers::selection_click(held_mods, app.preferences.selection_shift_add_only); crate::sketch_text::set_one_letter(mods.shift); + // Resting on the Line tool's last point switches lines and tangent arcs (#9489): only a free + // pointer, nothing held or under way. Not `inside`: on that point the pointer is between the + // new segment's value boxes, which take it from the view (as a shape being dragged out does). + let free_pointer = ui.is_enabled() + && !primary_down + && !secondary_down + && !middle + && !held + && primary_nav.is_none() + && mode.is_none() + && mods.is_none() + && !app.viewport.tool_click + && app.viewport.draw_drag.is_none(); + let resting = hover.filter(|p| free_pointer && rect.contains(*p) && !axes.contains(*p) && !crate::shell::covered(app, *p)); + crate::sketch_line_arc::follow_pointer(app, ui.ctx(), &proj, resting); if let Some(p) = hover.filter(|_| inside) { if app.tool.is_some() { crate::tools::on_hover(app, &proj, p, mods.shift); @@ -4522,6 +4594,14 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { if let Some((hit, Some(sel))) = cand { dialog_pick(app, &hit, sel); } else if let Some(mut dialog) = app.dialog.take() { + // A sketch clicked for a pattern's or Mirror's objects: say why it is not taken (#10057). + let sketch = hits.iter().find_map(|h| match h { + Hit::SketchCurve { sketch, .. } | Hit::SketchPoint { sketch, .. } | Hit::Profile { sketch, .. } => Some(*sketch), + _ => None, + }); + if let Some(sketch) = sketch { + dialog.refuse_sketch(&app.session, sketch); + } let state = crate::open_meshing::shown_world(app); if let Some(sketch) = app.session.active_sketch.and_then(|id| state.sketch(id)) { let (origin, direction) = proj.ray(p); @@ -4730,13 +4810,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { let st = crate::open_meshing::shown_world(app); app.session.active_sketch.and_then(|s| st.sketch(s)).and_then(|ss| { let sk = &ss.sketch; - let (mut own, mut exclude) = crate::drag_snap::moving_points(sk, &id); - for (o, x) in with.iter().map(|w| crate::drag_snap::moving_points(sk, w)) { - own.extend(o); - exclude.extend(x); - } - own.sort_unstable(); - own.dedup(); + let (own, exclude) = crate::drag_snap::moving_points(sk, std::iter::once(id.as_str()).chain(with.iter().map(String::as_str))); if own.iter().any(|&p| sk.point_locked(p)) { return crate::drag_snap::find(sk, &[], &[], 0.0); } @@ -4877,7 +4951,12 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { && !crate::shell::covered(app, a) && !axes.contains(a) { - app.viewport.boxsel = Some(BoxSel::from_drag(a, b)); + // The lasso (#4376) follows the pointer from the press; the rectangle spans it. + match &mut app.viewport.boxsel { + Some(lasso) if app.ui.lasso_select && lasso.is_lasso() => lasso.extend(b), + _ if app.ui.lasso_select => app.viewport.boxsel = Some(BoxSel::lasso(&[a, b])), + _ => app.viewport.boxsel = Some(BoxSel::from_drag(a, b)), + } } // A shell card disables its underlying widgets. The release still ends a // rubber band even when egui no longer sends drag_stopped to the viewport. @@ -4929,7 +5008,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { Tokens::view().warning, ); } - if let Some(bx) = app.viewport.boxsel { + if let Some(bx) = &app.viewport.boxsel { paint_box_selection(&painter, bx, app.session.active_sketch.is_some()); } if let Some(tl) = app.tool.as_ref() { @@ -4937,6 +5016,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { } crate::canvas::show(app, ui, &painter, &proj); crate::sketch_tools::show(app, ui, &painter, &proj); + crate::sketch_line_arc::paint_mode(app, ui, &painter, &proj); crate::design_properties::overlay(app, &painter, &proj); crate::sketch_dims::show(app, ui, &proj); crate::select_other::show(app, ui.ctx()); @@ -4976,7 +5056,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { crate::tool_cursor::show(app, &painter, badge_at); } -fn paint_box_selection(painter: &egui::Painter, bx: BoxSel, sketch: bool) { +fn paint_box_selection(painter: &egui::Painter, bx: &BoxSel, sketch: bool) { let t = Tokens::get(); let col = if sketch { t.sketch_accent @@ -4985,6 +5065,11 @@ fn paint_box_selection(painter: &egui::Painter, bx: BoxSel, sketch: bool) { } else { t.box_window }; + // The lasso is drawn as its closed outline: a fill of a concave loop would need tessellating. + if bx.is_lasso() { + painter.add(Shape::closed_line(bx.lasso.clone(), Stroke::new(1.2, col))); + return; + } painter.rect_filled(bx.rect, 0.0, col.gamma_multiply(0.12)); if bx.crossing { let c = [bx.rect.left_top(), bx.rect.right_top(), bx.rect.right_bottom(), bx.rect.left_bottom()]; @@ -5969,10 +6054,67 @@ fn origin_point_shown(app: &SolveApp) -> bool { app.origin_visible() && !app.session.visibility.hidden_origin.iter().any(|h| h == "O") } +/// Whether a body covers the origin along the view ray. The origin point is a flat dot painted +/// after the 3D scene, so the GPU cannot depth-test it the way it tests the axes and planes; +/// this asks the meshes the same question that test asks: is a nearer kept face in front of the +/// origin. The point then hides behind bodies under the same rule the axes and planes follow: +/// over them only while a dialog waits for a plane or the through-bodies preference is on (#10050). +fn origin_hidden_by_body(app: &SolveApp, proj: &Proj) -> bool { + if app.picking_plane() || app.preferences.reference_geometry_through_bodies { + return false; + } + // Wireframe draws no faces, so nothing hides the point, as for vertices and edges. + if app.ui.visual_style == 2 { + return false; + } + let Some(at) = proj.to_screen(Vec3::ZERO) else { + return false; + }; + let (o, d) = proj.ray(at); + let t_origin = -o.dot(d); + let st = crate::open_meshing::shown_world(app); + let bounds = body_bounds(app, &st); + // A section hides the material its normal points at: removed material does not hide the + // point, as when picking a vertex behind a section (#6328). + let section = section_plane(app); + let kept = |p: Vec3| section.is_none_or(|(so, sn)| sn.dot(p) <= sn.dot(so) + crate::view_pick::SECTION_EPS); + let walk_eps = 1e-6 * (1.0 + scene_radius(app)); + let slack = scene_pick_slack(app); + for (i, b) in st.bodies.iter().enumerate() { + if !app.body_visible(&b.name) { + continue; + } + // Bodies whose box the ray misses cannot cover the origin. + if let Some((_, bb)) = bounds.get(i) + && ray_box(o, d, bb, bb.diagonal() * 1e-6 + 1e-9).is_none() + { + continue; + } + let (m, place) = b.mesh_in_place(); + let index = app.viewport.pick_index.of(&m); + // The nearest kept face along the ray: one at or behind the origin does not hide it. + let mut from = 0.0f64; + for _ in 0..64 { + let Some((lo, ld)) = local_ray(&place, o + d * from, d) else { break }; + let Some((dt, _)) = crate::pick_index::raycast(index.as_deref(), &m, lo, ld) else { break }; + let t = from + dt; + if t >= t_origin - slack { + break; + } + if kept(o + d * t) { + return true; + } + from = t + walk_eps; + } + } + false +} + /// Screen-space dots: the origin point, and hovered or selected body vertices. fn points_2d(app: &SolveApp, painter: &egui::Painter, proj: &Proj) { let t = Tokens::get(); if origin_point_shown(app) + && !origin_hidden_by_body(app, proj) && let Some(o) = proj.to_screen(Vec3::ZERO) { painter.circle(o, 4.5, t.origin_point, Stroke::new(1.0, Color32::from_gray(110))); @@ -5992,21 +6134,29 @@ fn points_2d(app: &SolveApp, painter: &egui::Painter, proj: &Proj) { } /// A grid line's colour, as faint as the fade makes it. +/// The grid's minor colour blended towards its major one by `major` (0..1): a line two grid +/// levels share while they cross-fade, minor in one and major in the other (#9570). +fn grid_blend(minor: [u8; 4], major: [u8; 4], share: f32) -> [u8; 4] { + let t = if share.is_finite() { f64::from(share.clamp(0.0, 1.0)) } else { 0.0 }; + std::array::from_fn(|i| (f64::from(minor[i]) + (f64::from(major[i]) - f64::from(minor[i])) * t).round().clamp(0.0, 255.0) as u8) +} + fn grid_colour(c: [u8; 4], alpha: f32) -> [u8; 4] { [c[0], c[1], c[2], (f64::from(c[3]) * f64::from(alpha)).round().clamp(0.0, 255.0) as u8] } /// Where the ground grid is centred: where the view's axis (`back`, through the camera's target /// `tf`) meets the plane through the origin with normal `n`, so the middle of the view has grid -/// (#3087). At a grazing slant that point runs far off, and the ground in front of the camera -/// would have none: it stays within `ext` (the grid's half-size) of the point under the target. +/// (#3087). At a grazing slant, return smoothly toward the point under the target: the actual +/// intersection runs beyond the visible grid and jumps to the other side at the horizon. fn grid_centre(tf: Vec3, back: Vec3, n: Vec3, ext: f64) -> Vec3 { - let under = tf - n * tf.dot(n); + let height = tf.dot(n); + let under = tf - n * height; let slant = back.dot(n); - if slant.abs() <= 1e-3 { - return under; - } - let along = tf - back * (tf.dot(n) / slant) - under; + // Use the same minimum slant as grid_ext and GridFade. Above it, this is the view-axis + // intersection; below it, the offset tends continuously to zero instead of infinity. + let reach_slant = slant.abs().max(0.25); + let along = (back - n * slant) * (-height * slant / (reach_slant * reach_slant)); let len = along.len(); if len > ext && len > 0.0 { under + along * (ext / len) } else { under + along } } @@ -6302,7 +6452,7 @@ fn loose_end_markers( /// picture matches the window. The light theme's tokens (Off) are the engine's own colours, so a light /// render is unchanged. pub(crate) fn scene_palette(dark: bool, mode: crate::theme::ColorVision) -> solidcraft_engine::view::Palette { - let t = Tokens::themed(dark, mode); + let t = if dark == crate::theme::viewport_dark() && mode == crate::theme::vision() { Tokens::view() } else { Tokens::themed(dark, mode) }; let rgb = |c: Color32| Rgb(c.r(), c.g(), c.b()); solidcraft_engine::view::Palette { background: (rgb(t.viewport_top), rgb(t.viewport_bottom)), @@ -6494,7 +6644,7 @@ fn signed_axis_view(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { if frame.z.abs() > 0.5 { to.yaw = nearest_quarter_turn(app.cam.yaw); } - app.animate_to(to); + app.animate_turn(to); } } let home = Rect::from_center_size(pos2(c.x - 37.0, c.y - 37.0), vec2(18.0, 18.0)); @@ -6568,6 +6718,44 @@ mod tests { Ok(()) } + /// The origin point hides behind bodies under the same rule as the axes and planes (#10050): + /// a cylinder standing on the origin hides the dot while the through-bodies preference is + /// off, and point, axes and planes come through together with the preference on or while a + /// dialog waits for a plane. + #[test] + fn origin_point_hides_behind_bodies_like_the_axes_and_planes() { + let mut s = solidcraft_engine::Session::default(); + s.execute("solid.cylinder", &json!({"radius": 20, "height": 40})).unwrap(); + let mut app = SolveApp::new(s, crate::Services::default()); + app.fit_view(); + let rect = Rect::from_min_size(Pos2::ZERO, vec2(800., 600.)); + let proj = projection(&app, rect); + assert!(origin_point_shown(&app), "the Origin is shown by default"); + assert!(origin_hidden_by_body(&app, &proj), "the cylinder covering the origin hides the point"); + assert!(build_highlight(&app).overlay.is_empty(), "the axes and planes are depth-tested while the point is hidden"); + // The preference brings point, axes and planes through together (#2697). + crate::prefs::set(&mut app, &json!({"reference_geometry_through_bodies": true})).unwrap(); + assert!(!origin_hidden_by_body(&app, &proj), "with the preference the point shows through the body"); + assert!(!build_highlight(&app).overlay.is_empty(), "with the preference the axes and planes draw through"); + crate::prefs::set(&mut app, &json!({"reference_geometry_through_bodies": false})).unwrap(); + // A dialog waiting for a plane lifts all three as well (#2618). + app.dialog = Some(crate::dialogs::Dialog::for_command(&app, "sketch.create").unwrap()); + assert!(app.picking_plane()); + assert!(!origin_hidden_by_body(&app, &proj), "while a dialog picks a plane the point shows through"); + app.dialog = None; + // Wireframe draws no faces, so nothing hides the point, as for vertices and edges. + app.ui.visual_style = 2; + assert!(!origin_hidden_by_body(&app, &proj), "wireframe hides nothing"); + app.ui.visual_style = 0; + // A body away from the origin leaves the point drawn. + let mut s = solidcraft_engine::Session::default(); + s.execute("solid.box", &json!({"length": 20, "width": 20, "height": 20, "center": [200.0, 200.0, 200.0]})).unwrap(); + let mut app = SolveApp::new(s, crate::Services::default()); + app.fit_view(); + let proj = projection(&app, rect); + assert!(!origin_hidden_by_body(&app, &proj), "a body away from the origin does not cover it"); + } + #[test] fn straight_3d_wire_is_offered_as_an_axis_but_spline_is_refused() { for (command, points, straight) in [ @@ -7986,6 +8174,7 @@ mod tests { eye: [e[0], e[1], e[2], if proj.cam.fov > 0.0 { 1.0 } else { 0.0 }], ao_radius: 0.0, ao_samples: solidcraft_gpu::view::AO_SAMPLES, + sky: [0.0; 4], ssaa: 1.0, hover_tint: [0.0; 3], selection_tint: [0.0; 3], @@ -8701,6 +8890,74 @@ mod tests { } } + #[test] + fn touchpad_motion_with_focused_tool_fields_preserves_editing_and_pointer_ownership() { + for command in ["solid.move", "solid.extrude"] { + for canvas in [true, false] { + for alt in [true, false] { + let mut h = crate::scenario::Harness::new(); + h.app.preferences.nav = "touchpad".into(); + h.app.preferences.value_boxes_in_view = canvas; + if command == "solid.move" { + h.app.run("solid.box", json!({"length":30,"width":20,"height":10})).unwrap(); + h.app.session.selection = vec![Sel::Body { name: "Body1".into() }]; + } else { + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + h.app.run("sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[40,30]})).unwrap(); + h.app.run("sketch.finish", json!({})).unwrap(); + let sketch = h.app.session.doc.features[0].id; + h.app.session.selection = vec![Sel::Profile { sketch, index: 0 }]; + } + h.step(&json!({"start":command})).unwrap(); + h.frames(6); + let focus = h.ctx.memory(|m| m.focused()).expect("the tool's value field has focus"); + assert!(h.ctx.egui_wants_keyboard_input()); + let doc = h.app.session.doc.to_json(); + let undo = h.app.session.undo.len(); + let values = format!("{:?}", h.app.dialog.as_ref().unwrap().kind); + h.call("ui.move", json!({"x":600,"y":350})); + let before = h.app.cam; + h.call("ui.move", json!({"x":600,"y":350,"alt":alt,"shift":!alt})); + h.call("ui.move", json!({"x":660,"y":390,"alt":alt,"shift":!alt})); + if alt { + assert!((h.app.cam.yaw - before.yaw - 0.48).abs() < 1e-6, "{command}, canvas={canvas}: a focused field must allow orbit"); + assert_eq!(h.app.cam.target, before.target); + } else { + assert_ne!(h.app.cam.target, before.target, "{command}, canvas={canvas}: a focused field must allow pan"); + assert_eq!(h.app.cam.yaw, before.yaw); + } + assert_eq!(h.app.cam.distance, before.distance); + assert_eq!(h.ctx.memory(|m| m.focused()), Some(focus), "navigation must keep the field focused"); + + // The field owns pointer motion over itself, even while its keyboard focus + // no longer blocks navigation elsewhere. Leaving it must rearm without a jump. + let at = h.ctx.read_response(focus).unwrap().rect.center(); + let moved = h.app.cam; + h.call("ui.move", json!({"x":at.x,"y":at.y,"alt":alt,"shift":!alt})); + h.call("ui.move", json!({"x":at.x+1.0,"y":at.y,"alt":alt,"shift":!alt})); + assert_eq!(h.app.cam, moved, "pointer motion over a field must stay with the field"); + h.call("ui.move", json!({"x":600,"y":350,"alt":alt,"shift":!alt})); + assert_eq!(h.app.cam, moved, "returning to the viewport must not jump"); + h.call("ui.move", json!({"x":605,"y":350,"alt":alt,"shift":!alt})); + assert_ne!(h.app.cam, moved, "navigation resumes after leaving the field"); + let resumed = h.app.cam; + h.call("ui.move", json!({"x":600,"y":350})); + h.frames(3); + assert_eq!(h.app.cam, resumed, "releasing the modifier ends navigation"); + assert_eq!(format!("{:?}", h.app.dialog.as_ref().unwrap().kind), values); + assert_eq!(h.app.session.doc.to_json(), doc); + assert_eq!(h.app.session.undo.len(), undo); + assert_eq!(h.ctx.memory(|m| m.focused()), Some(focus)); + + h.call("ui.key", json!({"key":"A","cmd":true})); + h.call("ui.text", json!({"text":"7"})); + assert_eq!(h.app.dialog.as_mut().unwrap().primary().unwrap().2.as_str(), "7", "typing still reaches the focused field"); + assert_eq!(h.app.cam, resumed); + } + } + } + } + #[test] fn touchpad_motion_uses_the_effective_perspective_projection() { let mut app = hover_app(false); @@ -10243,6 +10500,99 @@ mod tests { assert_eq!(h.app.cam, stopped, "noise must leave the camera still"); } + /// A two-finger pan, a pinch and the wheel over a value box anchored to the model (Extrude's + /// distance) move the view as they do beside it (#10061): the box scrolls nothing, and a + /// gesture crossing it stopped. The box keeps its value. Dialogs keep theirs (below). + #[test] + fn trackpad_gestures_and_the_wheel_over_a_value_box_move_the_view() { + let mut h = crate::scenario::Harness::new(); + h.app.run("sketch.create", json!({"plane": "XY"})).unwrap(); + h.app.run("sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]})).unwrap(); + h.app.run("sketch.finish", json!({})).unwrap(); + let sketch = h.app.session.doc.features[0].id; + h.app.session.selection = vec![Sel::Profile { sketch, index: 0 }]; + h.step(&json!({"start": "solid.extrude"})).unwrap(); + h.frames(6); + let over = + |h: &crate::scenario::Harness| h.ctx.read_response(egui::Id::new("sc_canvas_value")).expect("Extrude's distance box").rect.center(); + let distance = |h: &crate::scenario::Harness| format!("{:?}", h.app.dialog.as_ref().map(|d| &d.kind)); + let typed = distance(&h); + let at = over(&h); + h.call("ui.move", json!({"x": at.x, "y": at.y})); + h.frames(2); + assert!(h.ctx.layer_id_at(at).is_some_and(crate::ui_kit::container::is_canvas), "the pointer is on the box"); + let gesture = + |phase| egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(30.0, -20.0), modifiers: egui::Modifiers::NONE, phase }; + let before = h.app.cam; + h.app.synthetic.push(gesture(egui::TouchPhase::Start)); + h.frame(); + h.app.synthetic.push(gesture(egui::TouchPhase::Move)); + h.frame(); + assert_ne!(h.app.cam.target, before.target, "a two-finger pan over the box pans"); + assert_eq!(h.app.cam.half_height(), before.half_height()); + h.app.synthetic.push(gesture(egui::TouchPhase::End)); + h.frames(30); + let at = over(&h); + h.call("ui.move", json!({"x": at.x, "y": at.y})); + h.frames(2); + let panned = h.app.cam; + h.app.synthetic.push(egui::Event::Zoom(1.2)); + h.frame(); + assert!(h.app.cam.half_height() < panned.half_height(), "a pinch over the box zooms"); + let at = over(&h); + h.call("ui.move", json!({"x": at.x, "y": at.y})); + h.frames(2); + let pinched = h.app.cam; + h.app.preferences.smooth_wheel_zoom = false; + h.call("ui.scroll", json!({"x": at.x, "y": at.y, "delta": 120.0})); + h.frames(2); + assert_ne!(h.app.cam.half_height(), pinched.half_height(), "the wheel over the box zooms"); + assert_eq!(distance(&h), typed, "the box kept its value"); + assert!(h.app.dialog.is_some()); + } + + /// #10128: a Shift + trackpad orbit must not halt when the spinning model moves a floating + /// value box (Extrude's distance) under the cursor. The box follows the geometry, so mid-orbit + /// it slides under a pointer that never moved; the orbit ignores it and keeps turning, frame + /// after frame, while the pointer is over the box. + #[test] + fn orbit_ignores_floating_field_under_cursor() { + let mut h = crate::scenario::Harness::new(); + h.app.run("sketch.create", json!({"plane": "XY"})).unwrap(); + h.app.run("sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]})).unwrap(); + h.app.run("sketch.finish", json!({})).unwrap(); + let sketch = h.app.session.doc.features[0].id; + h.app.session.selection = vec![Sel::Profile { sketch, index: 0 }]; + h.step(&json!({"start": "solid.extrude"})).unwrap(); + h.frames(6); + let over = + |h: &crate::scenario::Harness| h.ctx.read_response(egui::Id::new("sc_canvas_value")).expect("Extrude's distance box").rect.center(); + let typed = format!("{:?}", h.app.dialog.as_ref().map(|d| &d.kind)); + // The orbit is in progress and the box slides under the cursor: the pointer sits on the + // box every frame, as it does when the spinning model carries the box to the pointer. + let at = over(&h); + h.call("ui.move", json!({"x": at.x, "y": at.y})); + h.frames(2); + assert!(h.ctx.layer_id_at(at).is_some_and(crate::ui_kit::container::is_canvas), "the pointer is on the box"); + let orbit = + |phase| egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(24.0, -16.0), modifiers: egui::Modifiers::SHIFT, phase }; + h.app.synthetic.push(orbit(egui::TouchPhase::Start)); + h.frame(); + let mut last = h.app.cam; + for _ in 0..4 { + h.app.synthetic.push(orbit(egui::TouchPhase::Move)); + h.frame(); + let at = over(&h); + h.call("ui.move", json!({"x": at.x, "y": at.y})); + assert_ne!((h.app.cam.yaw, h.app.cam.pitch), (last.yaw, last.pitch), "the orbit keeps turning while the box is under the cursor"); + last = h.app.cam; + } + h.app.synthetic.push(orbit(egui::TouchPhase::End)); + h.frames(3); + assert_eq!(format!("{:?}", h.app.dialog.as_ref().map(|d| &d.kind)), typed, "the box kept its value"); + assert!(h.app.dialog.is_some()); + } + #[test] fn trackpad_gestures_over_dialogs_do_not_move_the_view() { let ctx = crate::fonts::test_context(); @@ -10539,6 +10889,56 @@ mod tests { } } + /// #10091: an axis tip turns the view in place by default; with Preferences › Navigation + /// "Fit the model after a navigator view change" on it also frames the whole model, as Home does. + #[test] + fn axis_tips_frame_the_model_when_the_navigation_preference_asks() { + let mut h = crate::scenario::Harness::new(); + h.call("ui.prefs", json!({"navigator_style":"axes", "look_at_anim":false})); + h.app.run("solid.box", json!({"corner":[100,0,0],"length":20,"width":20,"height":20})).unwrap(); + h.settle(); + let undo = h.app.session.undo.len(); + let away = |h: &mut crate::scenario::Harness| { + h.call("ui.view", json!({"view":"iso"})); + h.app.cam.pan(300.0, 200.0, 500.0); + h.app.cam.distance *= 4.0; + h.frames(2); + h.app.cam + }; + let from = away(&mut h); + let p = crate::scenario::handle_at("axis_view:-y").unwrap(); + h.call("ui.click", json!({"x":p.x,"y":p.y})); + h.frames(2); + assert!(h.app.cam.back().dist(-Vec3::Y) < 1e-6); + assert_eq!(h.app.cam.target, from.target, "off by default: the target stays"); + assert_eq!(h.app.cam.distance, from.distance, "off by default: the zoom stays"); + h.call("ui.prefs", json!({"navigator_fit":true})); + let from = away(&mut h); + let p = crate::scenario::handle_at("axis_view:-y").unwrap(); + h.call("ui.click", json!({"x":p.x,"y":p.y})); + h.frames(2); + assert!(h.app.cam.back().dist(-Vec3::Y) < 1e-6); + assert!(h.app.cam.target.dist(Vec3::new(110.0, 10.0, 10.0)) < 1e-6, "looks at the middle of the model, {:?}", h.app.cam.target); + assert!(h.app.cam.distance < from.distance / 2.0, "{} vs {}", h.app.cam.distance, from.distance); + // Home, then an axis: the zoom and the centre stay. + let home = crate::scenario::rect_of("axis_view:home").unwrap().center(); + h.call("ui.click", json!({"x":home.x,"y":home.y})); + h.frames(2); + let framed = h.app.cam; + for axis in ["axis_view:-y", "axis_view:z"] { + let p = crate::scenario::handle_at(axis).unwrap(); + h.call("ui.click", json!({"x":p.x,"y":p.y})); + h.frames(2); + assert!((h.app.cam.distance - framed.distance).abs() < 1e-6, "{axis}: {} vs Home's {}", h.app.cam.distance, framed.distance); + assert!(h.app.cam.target.dist(framed.target) < 1e-6, "{axis}: {:?} vs Home's {:?}", h.app.cam.target, framed.target); + } + h.call("ui.prefs", json!({"open":"Navigation"})); + h.call("ui.prefs", json!({"button":"defaults"})); + assert!(!h.app.preferences.navigator_fit, "Navigation defaults restore the in-place turn"); + h.call("ui.prefs", json!({"button":"cancel"})); + assert_eq!(h.app.session.undo.len(), undo, "a view change edits nothing"); + } + #[test] fn view_axis_drag_owns_navigation_and_keeps_the_view_centre() { for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] { @@ -11340,6 +11740,49 @@ mod tests { let items = h.app.dialog.as_ref().unwrap().inputs[0].items.clone(); assert!(!items.iter().any(|s| matches!(s, Sel::Profile { .. })), "a mask that asks for edges takes no region: {items:?}"); } + + /// #4376: with the Lasso selection shape, a drag in a sketch draws a freehand loop and takes + /// the curves wholly inside it. An L shaped loop leaves out a line in its notch and a line + /// that cuts across the notch, which no rectangle could; Shift adds to the selection. + #[test] + fn a_lasso_in_a_sketch_takes_the_curves_wholly_inside_it() { + let mut h = crate::scenario::Harness::new(); + h.app.run("sketch.create", json!({"plane": "XY"})).unwrap(); + let line = |h: &mut crate::scenario::Harness, a: [f64; 2], b: [f64; 2]| { + let made = h.app.run("sketch.line", json!({"points": [a, b]})).unwrap(); + made["created"]["curves"][0].as_str().unwrap().to_string() + }; + let bottom = line(&mut h, [25.0, 25.0], [75.0, 25.0]); + let left = line(&mut h, [25.0, 35.0], [25.0, 75.0]); + let notch = line(&mut h, [60.0, 60.0], [75.0, 75.0]); + let across = line(&mut h, [30.0, 75.0], [75.0, 30.0]); + let far = line(&mut h, [120.0, 25.0], [120.0, 75.0]); + h.call("ui.view", json!({"view": "top"})); + h.call("ui.view", json!({"view": "fit"})); + let r = h.call("ui.set", json!({"lassoSelect": true})); + assert_eq!(r["ok"], true, "{r}"); + let loop_at = |pts: &[[f64; 2]]| pts.iter().map(|p| json!({"world": [p[0], p[1], 0.0]})).collect::>(); + let curves = |h: &crate::scenario::Harness| { + h.app + .session + .selection + .iter() + .filter_map(|s| if let Sel::SketchCurve { id, .. } = s { Some(id.clone()) } else { None }) + .collect::>() + }; + let l = [[15.0, 15.0], [85.0, 15.0], [85.0, 45.0], [45.0, 45.0], [45.0, 85.0], [15.0, 85.0], [15.0, 20.0]]; + h.step(&json!({"drag": loop_at(&l), "steps": 6})).unwrap(); + assert!(h.app.viewport.boxsel.is_none(), "the release ends the lasso"); + assert_eq!(curves(&h), vec![bottom.clone(), left.clone()], "not {notch}, {across} or {far}"); + // Shift adds the far line with a second loop; drawn the other way round, it is the same. + let around_far = [[110.0, 15.0], [110.0, 85.0], [130.0, 85.0], [130.0, 15.0], [112.0, 15.0]]; + h.step(&json!({"drag": loop_at(&around_far), "steps": 6, "shift": true})).unwrap(); + assert_eq!(curves(&h), vec![bottom, left, far]); + // Back to the rectangle: a plain drag is a box again. + h.call("ui.set", json!({"lassoSelect": false})); + h.step(&json!({"drag": loop_at(&[[15.0, 15.0], [85.0, 85.0]])})).unwrap(); + assert_eq!(curves(&h).len(), 4, "the window box takes all four lines inside it: {:?}", curves(&h)); + } } #[cfg(test)] @@ -11509,6 +11952,49 @@ mod grid_plane_tests { assert!(c.len() < 1e-9, "{c:?}"); } + #[test] + fn ground_grid_centre_is_continuous_across_the_horizon() { + for normal in [Vec3::Y, Vec3::Z] { + for height in [-13.0, 0.0, 13.0] { + let target = Vec3::X * 2.0 + normal * height; + for extent in [120.0, 600.0] { + let centre = |step: i32| { + let pitch = f64::from(step) * 0.0001; + grid_centre(target, Vec3::X * pitch.cos() + normal * pitch.sin(), normal, extent) + }; + for step in -20..20 { + let (a, b) = (centre(step), centre(step + 1)); + assert!(a.dot(normal).abs() < 1e-9 && b.dot(normal).abs() < 1e-9); + assert!( + a.dist(b) < 0.1, + "a tiny orbit must not move the grid away: height {height}, extent {extent}, step {step}: {a:?} -> {b:?}" + ); + } + } + } + } + } + + #[test] + fn grazing_ground_grid_keeps_the_point_under_the_target_visible() { + let half_height = 124.55908448439891 * 0.4; + let major = grid_step(half_height).1; + for normal in [Vec3::Y, Vec3::Z] { + for height in [-12.966269622652957, 12.966269622652957] { + let target = Vec3::X * 2.1333385556362745 + normal * height; + let under = target - normal * height; + for pitch in [-0.125_f64, -0.08, -0.02, 0.0, 0.02, 0.08, 0.125] { + let back = Vec3::X * pitch.cos() + normal * pitch.sin(); + let slant = back.dot(normal); + let ext = grid_ext(half_height, major, slant); + let centre = grid_centre(target, back, normal, ext); + let fade = GridFade::new(half_height, slant, ext, f64::INFINITY); + assert_eq!(fade.alpha(centre.dist(under)), 1.0, "the grid must not recede from the model: height {height}, pitch {pitch}"); + } + } + } + } + /// #3087: zoomed in on a target below the ground (an orbit left it there), seen at a slant, /// the grid still covers the ground in the middle of the view: where the view's axis meets /// it, some fifty millimetres from the point under the target, round which it was drawn. @@ -11595,6 +12081,53 @@ mod grid_plane_tests { assert!(farthest < cut, "the grid fades out at {farthest}, short of the cut at {cut}"); } + /// #9570: zooming through a switch of the grid's spacing swapped the whole grid at once. At + /// a nested switch the window now draws both levels at half strength, each on its own + /// places; at the switch from 2 to 5, which do not nest, neither level's own lines; and a + /// line two levels share with different kinds takes a colour between the two. + #[test] + fn the_grid_draws_both_levels_at_a_switch_of_its_spacing() { + let rect = Rect::from_min_size(Pos2::ZERO, egui::vec2(800.0, 600.0)); + // The strongest alpha a line across X at `x` is drawn with, at a half height of `hh`. + let alpha_at = |hh: f64, x: f32| { + let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); + app.ui.show_grid = true; + app.ui.perspective = false; + app.cam.pitch = std::f64::consts::FRAC_PI_2; + app.cam.yaw = 0.0; + app.cam.target = Vec3::ZERO; + app.cam.distance = 1.0; + app.cam.distance = hh / app.cam.half_height(); + let scene = build_sketch_scene(&app, &projection(&app, rect)); + scene + .under + .as_chunks::<32>() + .0 + .iter() + .filter(|l| { + let f = |at: usize| f32::from_le_bytes(l[at..at + 4].try_into().unwrap()); + (f(0) - x).abs() < 1e-4 && (f(12) - x).abs() < 1e-4 + }) + .filter_map(|l| scene.palette.get(crate::gpu::PaletteIndex(u32::from_le_bytes(l[24..28].try_into().unwrap()))).map(|c| c[3])) + .max() + .unwrap_or(0) + }; + let full = u16::from(c4(Tokens::get().grid)[3]); + // A half height where the spacing switches from 5 to 10 mm (nested): the 5 mm lines + // cross-fade at about half strength. + let a = u16::from(alpha_at(100.0, 5.0)); + assert!(a > full / 4 && a < full * 3 / 4, "the line at 5 mm is drawn at {a} of {full}: both levels cross-fade"); + // From 2 to 5 mm (not nested) the finer level's own lines are gone before the coarser + // level's own lines appear: right at the switch neither is drawn, the shared 10 mm are. + for x in [2.0, 5.0] { + assert_eq!(alpha_at(50.0, x), 0, "the line at {x} mm is drawn while 2 and 5 swap"); + } + assert!(alpha_at(50.0, 10.0) > 0); + assert_eq!(grid_blend([0, 0, 0, 0], [200, 100, 50, 255], 0.5), [100, 50, 25, 128]); + assert_eq!(grid_blend([1, 2, 3, 4], [9, 9, 9, 9], 0.0), [1, 2, 3, 4]); + assert_eq!(grid_blend([1, 2, 3, 4], [9, 9, 9, 9], f32::NAN), [1, 2, 3, 4]); + } + /// #4214: the picture and the window draw the same grid. On a sketch's plane the headless /// render's grid used to reach further than the viewport's (the picture stretched the grid /// with the slant, the window did not), so a picture saved from the command line and the @@ -12063,3 +12596,22 @@ mod standalone_point_tests { assert!(standalone_points(&app).is_empty(), "sketches hidden"); } } + +#[cfg(test)] +mod feature_highlight_tests { + use super::*; + + /// A feature's faces take a new name when a later Offset Face moves them again; selecting + /// the feature still lights the faces it changed. + #[test] + fn a_feature_whose_face_was_renamed_later_still_lights_it() { + let mut s = solidcraft_engine::Session::default(); + s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20, "body_name": "Base"})).unwrap(); + let first = + s.execute("solid.offset_face", &json!({"faces": [[20, 15, 20]], "distance": 5, "name": "Raise"})).unwrap()["feature"].as_u64().unwrap(); + s.execute("solid.offset_face", &json!({"faces": [[20, 15, 25]], "distance": 2, "name": "RaiseAgain"})).unwrap(); + let app = SolveApp::new(s, crate::Services::default()); + let parts = feature_parts(&app, first, true); + assert!(parts.iter().any(|p| matches!(p, Sel::Face { .. })), "{parts:?}"); + } +} diff --git a/crates/ui-egui/src/viewport_details.rs b/crates/ui-egui/src/viewport_details.rs new file mode 100644 index 0000000000..e37c508f65 --- /dev/null +++ b/crates/ui-egui/src/viewport_details.rs @@ -0,0 +1,327 @@ +//! Current effect data -> the one generic details grammar; edits use normal commands. +use crate::{ + SolveApp, + ui_kit::{DetailEvent, DetailFinding, DetailKey, DetailSetting, DetailValue, Details}, +}; +use serde_json::{Value, json}; +use solidcraft_engine::{ + status_details::ContinuityDetail, + status_items::{StatusItem, StatusSetting}, +}; + +fn tr(id: &str) -> String { + crate::i18n::tr(id).to_owned() +} +fn field(id: &str, label: &str, value: DetailValue) -> DetailSetting { + DetailSetting { id: id.into(), label: tr(label), value, enabled: true } +} +fn length(app: &SolveApp, value: f64) -> DetailValue { + DetailValue::Length { value, units: app.session.doc.units.clone() } +} +fn number(value: f64, min: f64, max: f64, suffix: &str) -> DetailValue { + DetailValue::Number { value, min, max, suffix: suffix.into() } +} +fn axis(value: [f64; 3]) -> DetailValue { + let axes = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]; + let current = if axes.iter().any(|axis| *axis == value || axis.map(|v| -v) == value) { + crate::viewport_status::direction(value) + } else { + let [x, y, z] = value; + format!("({x}, {y}, {z})") + }; + DetailValue::Axis { value, current } +} +fn record_id(row: &ContinuityDetail) -> String { + json!([row.body, row.edge]).to_string() +} +fn record_label(row: &ContinuityDetail) -> String { + crate::i18n::format("ui.details_seam", &[("body", &row.body), ("edge", &row.edge.to_string())]) +} +fn selected<'a>(app: &SolveApp, rows: &'a [ContinuityDetail]) -> Option<&'a ContinuityDetail> { + app.viewport_status.selected_record.as_ref().and_then(|id| rows.iter().find(|row| record_id(row) == *id)).or_else(|| rows.first()) +} + +pub(crate) fn primary(effect: &StatusItem) -> &'static str { + match effect.setting { + StatusSetting::Draft { .. } => "angle", + StatusSetting::Zebra { .. } => "stripes", + StatusSetting::Accessibility { .. } => "direction", + StatusSetting::Curvature { .. } => "radius", + StatusSetting::Section { .. } => "offset", + StatusSetting::Continuity { .. } => "record", + StatusSetting::Environment => "", + } +} + +pub(crate) fn description(app: &SolveApp, effect: &StatusItem) -> Details { + let mut details = Details::default(); + match effect.setting { + StatusSetting::Draft { pull, angle_deg } => { + details.caption = Some(tr("ui.details_draft_caption")); + details.settings = vec![field("pull", "ui.details_pull", axis(pull)), field("angle", "ui.details_angle", DetailValue::Angle(angle_deg))]; + } + StatusSetting::Zebra { stripes } => { + details.caption = Some(tr("ui.details_zebra_caption")); + details.settings.push(field("stripes", "ui.details_stripes", DetailValue::Slider { value: stripes, min: 1.0, max: 200.0 })); + } + StatusSetting::Accessibility { direction } => { + details.caption = Some(tr("ui.details_access_caption")); + details.settings.push(field("direction", "ui.details_direction", axis(direction))); + } + StatusSetting::Curvature { radius_mm } => { + details.caption = Some(tr("ui.details_curvature_caption")); + details.settings.push(field("radius", "ui.details_radius", length(app, radius_mm))); + } + StatusSetting::Section { origin_mm, normal, hatch } => { + if let Some((_, offset, n)) = crate::viewport_status::section_coordinates(origin_mm, normal) { + let flip = normal.iter().zip(n).map(|(a, b)| a * b).sum::() < 0.0; + details.settings = vec![ + field("plane", "ui.details_plane", axis(n)), + field("offset", "ui.details_offset", length(app, offset)), + field("flip", "ui.details_flip", DetailValue::Check(flip)), + field("hatch", "ui.details_hatch", DetailValue::Check(hatch)), + ]; + } + } + StatusSetting::Continuity { .. } => continuity_description(app, &mut details), + StatusSetting::Environment => {} + } + if effect.command != "inspect.continuity" + && effect.has_key + && let Some((_, _, rows)) = crate::viewport::analysis_legend_data(app) + { + details.key = rows + .into_iter() + .enumerate() + .map(|(index, (colour, label, pattern))| DetailKey { + colour, + numeric: label.chars().any(|c| c.is_ascii_digit()), + label, + pattern: pattern || (matches!(effect.setting, StatusSetting::Draft { .. }) && index == 2), + value: String::new(), + pattern_colour: None, + }) + .collect(); + } + details +} + +fn continuity_description(app: &SolveApp, details: &mut Details) { + use solidcraft_engine::status_details::ContinuityVerdict as V; + let rows = app.session.continuity_details(); + let Some(row) = selected(app, &rows) else { return }; + details.caption = Some(tr("ui.details_continuity_caption")); + let index = rows.iter().position(|other| record_id(other) == record_id(row)).unwrap_or(0); + let (scale, unit) = crate::prefs::unit_scale(&app.session.doc.units); + details.settings = vec![ + field("record", "ui.details_edit_seam", DetailValue::Choice { selected: index, options: rows.iter().map(record_label).collect() }), + field("gap", "ui.details_gap", length(app, row.gap_tolerance_mm)), + field("angle", "ui.details_angle", DetailValue::Angle(row.angle_tolerance_deg)), + field("curvature", "ui.details_curvature", number(row.curvature_tolerance_per_mm / scale, 0.0, 1e9 / scale, &format!(" /{unit}"))), + field("samples", "ui.details_samples", number(row.samples as f64, 3.0, 257.0, "")), + ]; + for setting in details.settings.iter_mut().skip(1) { + setting.enabled = row.current; + } + if !row.current { + details.caption = Some(tr("ui.details_continuity_stale")); + } + let t = crate::theme::Tokens::get(); + for (label, colour, count) in [ + ("ui.details_smooth", t.continuity[0], rows.iter().filter(|s| s.verdict == V::Smooth).count()), + ("ui.details_jump", t.continuity[1], rows.iter().filter(|s| s.verdict == V::CurvatureJump).count()), + ("ui.details_sharp_gap", t.continuity[2], rows.iter().filter(|s| matches!(s.verdict, V::Sharp | V::Gap)).count()), + ] { + details.key.push(DetailKey { + colour, + pattern: label == "ui.details_sharp_gap", + label: tr(label), + value: count.to_string(), + numeric: false, + pattern_colour: (label == "ui.details_sharp_gap").then_some(t.continuity[3]), + }); + } + details.findings_title = tr("ui.details_worst_seams"); + details.findings_count = Some(rows.len().to_string()); + let mut findings: Vec<_> = rows.iter().collect(); + findings.sort_by(|a, b| { + b.verdict.cmp(&a.verdict).then_with(|| b.gap_mm.total_cmp(&a.gap_mm)).then_with(|| b.normal_angle_deg.total_cmp(&a.normal_angle_deg)) + }); + details.findings = findings + .into_iter() + .map(|row| { + let value = match row.verdict { + V::Gap => format!("G0 {}", crate::prefs::show_mm(app, row.gap_mm, 1)), + V::Sharp => format!("G1 {}°", row.normal_angle_deg), + V::CurvatureJump => format!("G2 {} /{unit}", row.curvature_jump_per_mm / scale), + V::Smooth => format!("G2 {} /{unit}", row.curvature_jump_per_mm / scale), + }; + DetailFinding { id: record_id(row), label: record_label(row), value, warning: row.verdict != V::Smooth, target: row.current } + }) + .collect(); + details.actions.push(("copy".into(), tr("ui.details_copy_list"))); +} + +/// Rebuild a complete command from current Session values for every single-field edit. +/// The value passed by the widget changes exactly one companion of that current command. +pub(crate) fn edit_params(app: &SolveApp, effect: &StatusItem, id: &str, value: &DetailValue) -> Option { + match effect.setting { + StatusSetting::Draft { pull, angle_deg } => { + Some(json!({"pull":if id=="pull" { axis_value(value)? } else {pull},"angle":if id=="angle"{scalar(value)?}else{angle_deg}})) + } + StatusSetting::Zebra { stripes } => Some(json!({"stripes":if id=="stripes"{scalar(value)?}else{stripes}})), + StatusSetting::Accessibility { direction } => Some(json!({"direction":if id=="direction"{axis_value(value)?}else{direction}})), + StatusSetting::Curvature { radius_mm } => Some(json!({"radius":if id=="radius"{scalar(value)?}else{radius_mm}})), + StatusSetting::Section { origin_mm, normal, hatch } => { + let (_, old_offset, n) = crate::viewport_status::section_coordinates(origin_mm, normal)?; + let mut base = origin_mm.into_iter().zip(n).map(|(o, n)| o - n * old_offset).collect::>(); + let new_n = if id == "plane" { axis_value(value)? } else { n }; + if id == "plane" { + // Project the base onto the new plane, preserving the signed offset companion. + let projection = base.iter().zip(new_n).map(|(o, n)| o * n).sum::(); + base = base.into_iter().zip(new_n).map(|(o, n)| o - n * projection).collect(); + } + let flip = normal.iter().zip(n).map(|(a, b)| a * b).sum::() < 0.0; + Some(json!({"plane":{"origin":base,"normal":new_n},"offset":format!("{} mm",if id=="offset"{scalar(value)?}else{old_offset}), + "flip":if id=="flip"{check(value)?}else{flip},"hatch":if id=="hatch"{check(value)?}else{hatch}})) + } + StatusSetting::Continuity { .. } => { + let rows = app.session.continuity_details(); + let row = selected(app, &rows).filter(|row| row.current)?; + let scale = crate::prefs::unit_scale(&app.session.doc.units).0; + let samples = if id == "samples" { scalar(value)? } else { row.samples as f64 }; + Some(json!({"body":row.body,"edges":[{"index":row.edge}],"add":true,"samples":samples, + "gap_tolerance_mm":if id=="gap"{scalar(value)?}else{row.gap_tolerance_mm}, + "angle_tolerance_deg":if id=="angle"{scalar(value)?}else{row.angle_tolerance_deg}, + "curvature_tolerance_per_mm":if id=="curvature"{scalar(value)?*scale}else{row.curvature_tolerance_per_mm}})) + } + StatusSetting::Environment => None, + } +} +fn scalar(value: &DetailValue) -> Option { + match value { + DetailValue::Number { value, .. } | DetailValue::Length { value, .. } | DetailValue::Slider { value, .. } | DetailValue::Angle(value) => { + Some(*value) + } + _ => None, + } +} +fn check(value: &DetailValue) -> Option { + if let DetailValue::Check(value) = value { Some(*value) } else { None } +} +fn axis_value(value: &DetailValue) -> Option<[f64; 3]> { + if let DetailValue::Axis { value, .. } = value { Some(*value) } else { None } +} + +pub(crate) fn dispatch(app: &mut SolveApp, ctx: &egui::Context, owner: &str, event: DetailEvent) { + let Some(effect) = app.session.status_items().into_iter().find(|effect| crate::viewport_status::identity(effect) == owner) else { return }; + match event { + DetailEvent::Change(id, DetailValue::Choice { selected, .. }) if id == "record" && effect.command == "inspect.continuity" => { + app.viewport_status.selected_record = app.session.continuity_details().get(selected).map(record_id); + } + DetailEvent::Change(id, value) => { + if let Some(params) = edit_params(app, &effect, &id, &value) + && let Err(error) = app.run(&effect.command, params) + { + app.set_status(error, true); + } + } + DetailEvent::Target(id) if effect.command == "inspect.continuity" => { + let Some((body, edge)) = serde_json::from_str::<(String, usize)>(&id).ok() else { return }; + let Some(points) = app.session.continuity_target_points(&body, edge) else { return }; + let mut bounds = solidcraft_engine::geom::Aabb3::EMPTY; + for point in points { + bounds.add(*point); + } + if bounds.is_empty() { + return; + } + let mut camera = app.cam; + camera.fit(&bounds); + let params = json!({"camera":{"target":camera.target,"yaw":camera.yaw,"pitch":camera.pitch,"distance":camera.distance,"fov":camera.fov,"roll":camera.roll}}); + if let Err(error) = app.run("view.apply", params) { + app.set_status(error, true); + } + } + DetailEvent::Action(id) if id == "copy" && effect.command == "inspect.continuity" => { + let rows = app.session.continuity_details(); + if let Ok(text) = serde_json::to_string_pretty(&rows) { + ctx.copy_text(text.clone()); + app.clipboard_text = Some(text); + } + } + DetailEvent::Action(_) | DetailEvent::Target(_) => {} + } +} + +pub(crate) fn legacy_description(app: &SolveApp) -> Details { + let mut details = Details::default(); + if let Some((title, along, rows)) = crate::viewport::analysis_legend_data(app) { + details.caption = Some(along.map_or_else(|| title.to_owned(), |along| format!("{title}\n{along}"))); + details.key = rows + .into_iter() + .map(|(colour, label, pattern)| DetailKey { + colour, + numeric: label.chars().any(|c| c.is_ascii_digit()), + label, + pattern, + value: String::new(), + pattern_colour: None, + }) + .collect(); + } + details +} + +/// Compatibility placement uses the same description and renderer, never a second painter. +pub(crate) fn legacy_legends(app: &SolveApp, ui: &mut egui::Ui, viewport: egui::Rect) { + let mut descriptions = Vec::new(); + if !app.preferences.viewport_analysis_status { + descriptions.push(("analysis:legend", legacy_description(app))); + } + if (!app.preferences.viewport_analysis_status || !app.preferences.viewport_status_details) && !app.session.continuity.is_empty() { + let t = crate::theme::Tokens::get(); + let key = ["ui.continuity_g2", "ui.continuity_g1", "ui.continuity_g0", "ui.continuity_gap"] + .into_iter() + .zip(t.continuity) + .enumerate() + .map(|(index, (label, colour))| DetailKey { + colour, + label: tr(label), + pattern: index == 3, + value: String::new(), + numeric: false, + pattern_colour: None, + }) + .collect(); + descriptions.push(("continuity_legend", Details { key, ..Default::default() })); + } + let mut free = crate::shell::workspace_content(app, viewport); + for (id, description) in descriptions { + if let Some(rect) = legacy_legend(app, ui, free, id, &description) { + free.max.y = (rect.top() - crate::theme::Tokens::get().space_8).max(free.top()); + } + } +} + +fn legacy_legend(_app: &SolveApp, ui: &mut egui::Ui, free: egui::Rect, id: &str, description: &Details) -> Option { + if description.key.is_empty() { + return None; + } + let t = crate::theme::Tokens::get(); + let container = crate::ui_kit::Container::new(ui.ctx(), crate::ui_kit::Surface::ViewportStatus); + let result = container + .area(egui::Id::new(id)) + .order(egui::Order::Background) + .movable(false) + .pivot(egui::Align2::LEFT_BOTTOM) + .fixed_pos(free.left_bottom() + egui::vec2(t.space_12, -t.space_12)) + .show(ui.ctx(), |ui| { + let frame = container.frame(ui.ctx(), t.overlay, crate::frame::Side::Left).inner_margin(t.space_8); + container.show_frame(ui, frame, free, |ui| { + crate::ui_kit::details(ui, id, description, free.height() * 0.6, None); + }); + }); + crate::scenario::publish_target(id, result.response.rect); + Some(result.response.rect) +} diff --git a/crates/ui-egui/src/viewport_layout.rs b/crates/ui-egui/src/viewport_layout.rs index a9f9923b8d..b3344f27c5 100644 --- a/crates/ui-egui/src/viewport_layout.rs +++ b/crates/ui-egui/src/viewport_layout.rs @@ -185,11 +185,19 @@ pub(crate) fn fit_product_bounds(cam: Camera, bounds: &Aabb3, layout: &Layout) - fit_bounds(cam, bounds, layout, cam.fov) } -pub(crate) fn fit_bounds(mut cam: Camera, bounds: &Aabb3, layout: &Layout, fov: f64) -> Option { +pub(crate) fn fit_bounds(cam: Camera, bounds: &Aabb3, layout: &Layout, fov: f64) -> Option { + fit_bounds_sized(cam, bounds, layout, fov, 1.0) +} + +/// [`fit_bounds`] with the model filling `share` (0.1 to 1) of the room between the panels, the +/// compass and the navigation bar instead of all of it: the Navigation preference "Home and Fit +/// size" (#10091). The model stays clear of what covers the view at every size. +pub(crate) fn fit_bounds_sized(mut cam: Camera, bounds: &Aabb3, layout: &Layout, fov: f64, share: f64) -> Option { let perspective = fov > 0.0; if !fov.is_finite() || !(0.0..std::f64::consts::PI).contains(&fov) { return None; } + let share = if share.is_finite() { share.clamp(0.1, 1.0) } else { 1.0 }; if bounds.is_empty() || !bounds.min.is_finite() || !bounds.max.is_finite() @@ -237,7 +245,7 @@ pub(crate) fn fit_bounds(mut cam: Camera, bounds: &Aabb3, layout: &Layout, fov: if !room.is_positive() || !room.is_finite() { return None; } - let hh = (hx * h / f64::from(room.width())).max(hy * h / f64::from(room.height())).max(0.5); + let hh = (hx * h / f64::from(room.width())).max(hy * h / f64::from(room.height())).max(0.5) / share; let offset = room.center() - layout.viewport.center(); let tangent = if perspective { (fov * 0.5).tan() } else { 0.4 }; cam.distance = hh / tangent + if perspective { depth } else { 0.0 }; diff --git a/crates/ui-egui/src/viewport_overlays_tests.rs b/crates/ui-egui/src/viewport_overlays_tests.rs index 7208498967..d3aa419da0 100644 --- a/crates/ui-egui/src/viewport_overlays_tests.rs +++ b/crates/ui-egui/src/viewport_overlays_tests.rs @@ -1,6 +1,432 @@ use super::*; use crate::{scenario::Harness, shell::LayoutMode}; +#[test] +fn details_descriptions_are_pure_and_live_edits_preserve_custom_companions() { + use crate::ui_kit::DetailValue as V; + let mut h = Harness::new(); + h.app.run("inspect.draft", json!({"pull":[-2,3,-4],"angle":14.5})).unwrap(); + let effect = h.app.session.status_items().remove(0); + let before = (h.app.session.revision, h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.status_items()); + let details = crate::viewport_details::description(&h.app, &effect); + assert_eq!(details.settings.len(), 2); + assert_eq!(details.key.len(), 3); + assert!(details.caption.as_ref().is_some_and(|caption| !caption.contains("Draft"))); + assert!(matches!(details.settings[0].value,V::Axis{value,..} if value[0]<0.0 && value[1]>0.0 && value[2]<0.0)); + assert_eq!((h.app.session.revision, h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.status_items()), before); + let params = crate::viewport_details::edit_params(&h.app, &effect, "angle", &V::Angle(8.25)).unwrap(); + let original = h.app.session.analysis.unwrap(); + h.app.run(&effect.command, params).unwrap(); + match (original, h.app.session.analysis.unwrap()) { + (solidcraft_engine::SurfaceAnalysis::Draft { pull: a, .. }, solidcraft_engine::SurfaceAnalysis::Draft { pull: b, angle }) => { + assert_eq!(a, b); + assert_eq!(angle, 8.25); + } + _ => panic!("real Draft command expected"), + } + h.app.run("inspect.zebra", json!({"stripes":14.5})).unwrap(); + let effect = h.app.session.status_items().remove(0); + assert!(matches!(crate::viewport_details::description(&h.app,&effect).settings[0].value,V::Slider{value,..} if value==14.5)); + assert_eq!( + crate::viewport_details::edit_params(&h.app, &effect, "stripes", &V::Slider { value: 18.75, min: 1.0, max: 200.0 }).unwrap()["stripes"], + 18.75 + ); + h.app.run("inspect.accessibility", json!({"direction":[1,-2,3]})).unwrap(); + let effect = h.app.session.status_items().remove(0); + let before = h.app.session.analysis; + for _ in 0..3 { + crate::viewport_details::description(&h.app, &effect); + } + assert_eq!(h.app.session.analysis, before, "drawing never snaps arbitrary vectors to an axis"); +} + +#[test] +fn section_details_live_fields_reconstruct_arbitrary_plane_without_losing_companions() { + use crate::ui_kit::DetailValue as V; + let mut h = Harness::new(); + h.app.run("inspect.section", json!({"plane":{"origin":[12,-9,7],"normal":[-3,4,-2]},"offset":"-5 mm","flip":true,"hatch":false})).unwrap(); + let original = h.app.session.section.unwrap(); + let effect = h.app.session.status_items().remove(0); + let params = crate::viewport_details::edit_params(&h.app, &effect, "hatch", &V::Check(true)).unwrap(); + h.app.run("inspect.section", params).unwrap(); + let changed = h.app.session.section.unwrap(); + assert!((original.0 - changed.0).len() < 1e-9 && (original.1 - changed.1).len() < 1e-9); + assert!(h.app.session.section_hatch); + let effect = h.app.session.status_items().remove(0); + let details = crate::viewport_details::description(&h.app, &effect); + let current_flip = match details.settings.iter().find(|setting| setting.id == "flip").unwrap().value { + V::Check(value) => value, + _ => panic!("actual flip checkbox"), + }; + assert!(!current_flip, "the first-positive canonical normal already agrees with this actual flipped negative input"); + let params = crate::viewport_details::edit_params(&h.app, &effect, "flip", &V::Check(!current_flip)).unwrap(); + assert_eq!(params["hatch"], true); + h.app.run("inspect.section", params).unwrap(); + let flipped = h.app.session.section.unwrap(); + assert!((original.0 - flipped.0).len() < 1e-9); + assert!((original.1 + flipped.1).len() < 1e-9); + h.app.session.doc_mut().units = "in".into(); + let effect = h.app.session.status_items().remove(0); + let params = crate::viewport_details::edit_params(&h.app, &effect, "offset", &V::Length { value: -25.4, units: "in".into() }).unwrap(); + assert_eq!(params["offset"], "-25.4 mm"); + assert_eq!(params["hatch"], true); + assert_eq!(params["flip"], !current_flip); + h.app.run("inspect.section", params).unwrap(); + assert!((h.app.session.section.unwrap().1 - flipped.1).len() < 1e-9); +} + +#[test] +fn details_record_scope_preserves_mixed_limits_and_stale_targets_never_move_camera() { + use crate::ui_kit::{DetailEvent, DetailValue as V}; + let mut h = Harness::new(); + h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap(); + h.app + .run( + "inspect.continuity", + json!({"edges":[[5,0,0]],"samples":5,"gap_tolerance_mm":0.002,"angle_tolerance_deg":2,"curvature_tolerance_per_mm":0.03}), + ) + .unwrap(); + h.app + .run( + "inspect.continuity", + json!({"edges":[[0,5,0]],"samples":9,"gap_tolerance_mm":0.008,"angle_tolerance_deg":4,"curvature_tolerance_per_mm":0.06,"add":true}), + ) + .unwrap(); + let effect = h.app.session.status_items().remove(0); + let owner = crate::viewport_status::identity(&effect); + let rows = h.app.session.continuity_details(); + h.app.viewport_status.selected_record = Some(json!([rows[1].body, rows[1].edge]).to_string()); + h.app.session.doc_mut().units = "in".into(); + let description = crate::viewport_details::description(&h.app, &effect); + assert_eq!(description.findings.len(), 2); + assert!(matches!(description.settings[3].value,V::Number{value,..} if (value-0.06*25.4).abs()<1e-12)); + let params = + crate::viewport_details::edit_params(&h.app, &effect, "samples", &V::Number { value: 11.0, min: 3.0, max: 257.0, suffix: String::new() }) + .unwrap(); + assert_eq!(params["edges"][0]["index"], rows[1].edge); + assert_eq!(params["gap_tolerance_mm"], 0.008); + assert_eq!(params["angle_tolerance_deg"], 4.0); + assert_eq!(params["curvature_tolerance_per_mm"], 0.06); + h.app.run("inspect.continuity", params).unwrap(); + assert_eq!(h.app.session.continuity.iter().find(|row| row.edge == rows[0].edge).unwrap().details, rows[0]); + crate::viewport_details::dispatch(&mut h.app, &h.ctx, &owner, DetailEvent::Action("copy".into())); + let copied: serde_json::Value = serde_json::from_str(h.app.clipboard_text.as_deref().unwrap()).unwrap(); + assert_eq!(copied.as_array().unwrap().len(), 2); + assert!(copied.as_array().unwrap().iter().any(|row| row["samples"] == 11)); + let target = json!([rows[1].body, rows[1].edge]).to_string(); + crate::viewport_details::dispatch(&mut h.app, &h.ctx, &owner, DetailEvent::Target(target.clone())); + assert!(h.app.session.camera.is_some(), "real view.apply is dispatched"); + h.app.run("body.rename", json!({"body":rows[1].body,"name":"Changed"})).unwrap(); + let before = h.app.session.camera; + crate::viewport_details::dispatch(&mut h.app, &h.ctx, &owner, DetailEvent::Target(target)); + assert_eq!(h.app.session.camera, before); + let effect = h.app.session.status_items().remove(0); + assert!(crate::viewport_details::edit_params(&h.app, &effect, "gap", &V::Length { value: 1.0, units: "in".into() }).is_none()); + assert!(crate::viewport_details::description(&h.app, &effect).findings.iter().all(|row| !row.target)); +} + +#[test] +fn modern_details_cards_open_focus_suspend_and_close_before_effects() { + for (command, params, field) in [ + ("inspect.draft", json!({"pull":[-1,2,3],"angle":3}), "angle"), + ("inspect.zebra", json!({"stripes":14.5}), "stripes"), + ("inspect.section", json!({"plane":"XY","at":4}), "offset"), + ] { + let mut h = Harness::new(); + h.app.run(command, params).unwrap(); + h.frames(3); + let effect = h.app.session.status_items().remove(0); + let item = crate::viewport_status::presentation(&h.app, &effect).unwrap(); + assert!(item.has_key && item.editable); + h.step(&json!({"click":{"handle":format!("viewport_status:{}:setting",item.id)}})).unwrap(); + h.frames(3); + assert!(h.app.dialog.is_none()); + assert!(crate::scenario::rect_of(&format!("details:field:{field}")).is_some()); + assert!( + crate::ui_kit::details_field_owns_focus(&h.ctx), + "{command}: the corresponding visible native value must own focus, actual {:?}", + h.ctx.memory(|memory| memory.focused()) + ); + h.step(&json!({"key":"Escape"})).unwrap(); + assert!(h.app.viewport_status.open_key.is_some(), "{command}: the focused value owns the first Escape"); + h.step(&json!({"key":"Escape"})).unwrap(); + assert!(h.app.viewport_status.open_key.is_none()); + assert_eq!(h.app.session.status_items().len(), 1); + h.step(&json!({"click":{"handle":format!("viewport_status:{}:key",item.id)}})).unwrap(); + h.call("ui.prefs", json!({"open":"Interface"})); + h.frames(3); + assert!(crate::scenario::rect_of("viewport_status:card").is_none(), "modern card suspended beneath Preferences"); + h.step(&json!({"key":"Escape"})).unwrap(); + assert!(h.app.viewport_status.open_key.is_some()); + assert_eq!(h.app.session.status_items().len(), 1); + h.frames(3); + h.step(&json!({"key":"Escape"})).unwrap(); + assert!(h.app.viewport_status.open_key.is_none()); + h.step(&json!({"key":"Escape"})).unwrap(); + assert!(h.app.session.status_items().is_empty()); + } +} + +#[test] +fn details_native_value_edits_run_real_commands_without_design_history() { + for (command, params, field, text, expected) in [ + ("inspect.draft", json!({"pull":[-2,3,-4],"angle":3.5}), "angle", "8.25", 8.25), + ("inspect.zebra", json!({"stripes":14.5}), "stripes", "17.25", 17.25), + ("inspect.section", json!({"plane":"XY","at":"25.4 mm","hatch":false}), "offset", "1.5", 38.1), + ] { + let mut h = Harness::new(); + h.app.session.doc_mut().units = "in".into(); + h.app.run(command, params).unwrap(); + h.frames(3); + let before = (h.app.session.doc.to_json(), h.app.session.undo.len()); + let effect = h.app.session.status_items().remove(0); + let owner = crate::viewport_status::identity(&effect); + h.step(&json!({"click":{"handle":format!("viewport_status:{owner}:setting")}})).unwrap(); + h.frames(3); + let rect = crate::scenario::rect_of(&format!("details:field:{field}")).unwrap(); + let at = if field == "stripes" { rect.right_center() - vec2(crate::theme::Tokens::get().space_24, 0.0) } else { rect.center() }; + h.call("ui.click", json!({"x":at.x,"y":at.y,"double":true})); + h.step(&json!({"key":"A","ctrl":true})).unwrap(); + h.step(&json!({"text":text})).unwrap(); + h.step(&json!({"key":"Enter"})).unwrap(); + h.frames(3); + let value = match h.app.session.status_items()[0].setting { + solidcraft_engine::status_items::StatusSetting::Draft { angle_deg, .. } => angle_deg, + solidcraft_engine::status_items::StatusSetting::Zebra { stripes } => stripes, + solidcraft_engine::status_items::StatusSetting::Section { origin_mm, .. } => origin_mm[2], + _ => panic!("real edited setting expected"), + }; + assert!((value - expected).abs() < 1e-9, "native {field}: {value} instead of {expected}"); + assert_eq!((h.app.session.doc.to_json(), h.app.session.undo.len()), before); + assert!(h.app.dialog.is_none()); + } +} + +#[test] +fn details_owned_dropdown_suspends_without_leaking_above_preferences_or_mode_changes() { + let mut h = Harness::new(); + h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap(); + h.app.run("inspect.continuity", json!({})).unwrap(); + h.frames(3); + let owner = crate::viewport_status::identity(&h.app.session.status_items()[0]); + h.step(&json!({"click":{"handle":format!("viewport_status:{owner}:key")}})).unwrap(); + h.frames(3); + h.step(&json!({"click":{"handle":"details:field:record"}})).unwrap(); + h.frames(3); + assert!(egui::Popup::is_any_open(&h.ctx)); + let before = h.app.session.continuity.clone(); + h.call("ui.prefs", json!({"open":"Interface"})); + h.frames(3); + assert!(!egui::Popup::is_any_open(&h.ctx)); + assert!(crate::scenario::rect_of("viewport_status:card").is_none()); + assert!(!h.app.viewport_status.owns_focus(&h.ctx)); + assert_eq!(h.app.session.continuity, before); + h.step(&json!({"key":"Escape"})).unwrap(); + h.frames(3); + assert!(h.app.viewport_status.open_key.is_some()); + h.call("ui.prefs", json!({"viewport_status_details":false})); + h.frames(3); + assert!(h.app.viewport_status.open_key.is_none()); + assert!(!egui::Popup::is_any_open(&h.ctx)); + assert!(!crate::ui_kit::details_field_owns_focus(&h.ctx)); + assert_eq!(h.app.session.continuity, before); +} + +#[test] +fn details_preference_and_analysis_hud_policies_are_independent() { + for modern in [false, true] { + for hud in [false, true] { + let mut h = Harness::new(); + h.call("ui.prefs", json!({"viewport_status_details":modern,"viewport_analysis_status":hud,"viewport_status_summary":false})); + h.app.run("inspect.draft", json!({"angle":3.5})).unwrap(); + h.frames(3); + assert_eq!(crate::scenario::rect_of("analysis:legend").is_some(), !hud); + let effect = h.app.session.status_items().remove(0); + if hud { + let description = + if modern { crate::viewport_details::description(&h.app, &effect) } else { crate::viewport_details::legacy_description(&h.app) }; + assert_eq!(description.settings.is_empty(), !modern); + assert_eq!(description.key.len(), 3); + } + h.app.run("inspect.zebra", json!({"stripes":14.5})).unwrap(); + let effect = h.app.session.status_items().remove(0); + assert_eq!(crate::viewport_status::presentation(&h.app, &effect).is_some_and(|item| item.has_key), hud && modern); + h.app.run("inspect.section", json!({"plane":"XY"})).unwrap(); + let section = h.app.session.status_items().into_iter().find(|effect| effect.command == "inspect.section").unwrap(); + let item = crate::viewport_status::presentation(&h.app, §ion).unwrap(); + assert_eq!(item.has_key, modern); + crate::viewport_status::dispatch(&mut h.app, vec![crate::ui_kit::StatusAction::Edit(item.id)]); + assert_eq!(h.app.dialog.is_some(), !modern); + assert_eq!(h.app.viewport_status.open_key.is_some(), modern); + } + } +} + +#[test] +fn details_findings_and_readout_scroll_inside_with_fixed_caption_and_actions() { + for theme in ["light", "dark"] { + for vision in ["off", "protan", "deutan", "tritan"] { + for (preset, counter) in [("details_findings", "details:findings_visible"), ("details_readout", "details:readout_visible")] { + let mut h = Harness::new(); + h.call("ui.prefs", json!({"theme":theme,"color_vision":vision})); + let before = (h.app.session.doc.to_json(), h.app.session.revision, h.app.session.status_items()); + h.call("ui.status_fixture", json!({"preset":preset})); + h.frames(3); + h.step(&json!({"click":{"handle":"viewport_status:details:key"}})).unwrap(); + h.frames(3); + assert_eq!(crate::scenario::count_of(counter), Some(6.0)); + let caption = crate::scenario::rect_of("details:caption").unwrap(); + let actions = crate::scenario::rect_of("details:actions").unwrap(); + let body = crate::scenario::rect_of("details:body").unwrap(); + let card = crate::scenario::rect_of("viewport_status:card").unwrap(); + let viewport = h.app.viewport.layout.as_ref().unwrap().viewport; + assert!(card.height() <= viewport.height() * 0.6 + 1.0, "{theme}/{vision}/{preset}: {card:?}"); + let camera = h.app.cam; + h.call("ui.scroll", json!({"x":body.center().x,"y":body.center().y,"delta":-1800})); + h.frames(3); + assert_eq!(h.app.cam, camera, "scrolling a card must not zoom the CAD view"); + assert_eq!(crate::scenario::rect_of("details:caption").unwrap(), caption); + assert_eq!(crate::scenario::rect_of("details:actions").unwrap(), actions); + h.step(&json!({"click":{"handle":"details:show_all"}})).unwrap(); + h.frames(3); + assert_eq!(crate::scenario::count_of(counter), Some(9.0), "Show all must expose backed rows"); + assert_eq!( + (h.app.session.doc.to_json(), h.app.session.revision, h.app.session.status_items()), + before, + "clearly separate specimens never create production results" + ); + } + } + } +} + +#[test] +fn details_outside_close_returns_live_owner_focus_and_replacement_reconciles() { + let mut h = Harness::new(); + h.app.run("inspect.draft", json!({"angle":3})).unwrap(); + h.frames(3); + let owner = crate::viewport_status::identity(&h.app.session.status_items()[0]); + h.step(&json!({"click":{"handle":format!("viewport_status:{owner}:key")}})).unwrap(); + h.frames(3); + let viewport = h.app.viewport.layout.as_ref().unwrap().viewport; + let outside = viewport.right_bottom() - vec2(60.0, 60.0); + h.call("ui.click", json!({"x":outside.x,"y":outside.y})); + h.frames(3); + assert!(h.app.viewport_status.open_key.is_none()); + assert!(h.app.viewport_status.owns_focus(&h.ctx), "focus returns to the still-present arrow"); + h.step(&json!({"click":{"handle":format!("viewport_status:{owner}:key")}})).unwrap(); + h.frames(3); + h.app.run("inspect.zebra", json!({"stripes":14.5})).unwrap(); + h.frames(3); + assert!(h.app.viewport_status.open_key.is_none()); + assert!(matches!(h.app.session.analysis,Some(solidcraft_engine::SurfaceAnalysis::Zebra{stripes}) if stripes==14.5)); + assert!(crate::scenario::rect_of("viewport_status:card").is_none()); +} + +#[test] +fn details_document_identity_releases_equal_slot_owners_and_native_buffers() { + let mut h = Harness::new(); + h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap(); + h.app.run("inspect.draft", json!({"angle":3})).unwrap(); + h.frames(3); + let uid = h.app.session.uid; + let owner = crate::viewport_status::identity(&h.app.session.status_items()[0]); + h.step(&json!({"click":{"handle":format!("viewport_status:{owner}:setting")}})).unwrap(); + h.frames(3); + assert!(crate::ui_kit::details_field_owns_focus(&h.ctx)); + h.call("ui.documents", json!({"action":"new"})); + h.app.run("solid.box", json!({"length":20,"width":10,"height":10})).unwrap(); + h.app.run("inspect.draft", json!({"angle":7})).unwrap(); + h.frames(3); + assert_ne!(h.app.session.uid, uid); + assert_eq!( + crate::viewport_status::identity(&h.app.session.status_items()[0]), + owner, + "equal command/slot identities really exercise document ownership" + ); + assert!(h.app.viewport_status.open_key.is_none()); + assert!(!crate::ui_kit::details_field_owns_focus(&h.ctx)); + h.step(&json!({"click":{"handle":format!("viewport_status:{owner}:setting")}})).unwrap(); + h.frames(3); + assert!(crate::ui_kit::details_field_owns_focus(&h.ctx)); + h.call("ui.documents", json!({"action":"switch","index":0})); + h.frames(3); + assert_eq!(h.app.session.uid, uid); + assert!(h.app.viewport_status.open_key.is_none()); + assert!(!egui::Popup::is_any_open(&h.ctx)); + assert!(matches!(h.app.session.analysis,Some(solidcraft_engine::SurfaceAnalysis::Draft{angle,..}) if angle==3.0)); +} + +#[test] +fn details_grouped_continuity_key_contains_the_actual_sharp_and_gap_tokens() { + for theme in ["light", "dark"] { + for vision in ["off", "protan", "deutan", "tritan"] { + let mut h = Harness::new(); + h.call("ui.prefs", json!({"theme":theme,"color_vision":vision})); + h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap(); + h.app.run("inspect.continuity", json!({})).unwrap(); + h.frames(3); + let effect = h.app.session.status_items().remove(0); + let details = crate::viewport_details::description(&h.app, &effect); + let key = &details.key[2]; + let tokens = crate::theme::Tokens::get(); + assert_eq!(key.value, "12"); + assert_eq!(key.colour, tokens.continuity[2], "actual cube creases use the Sharp scene token"); + assert_eq!(key.pattern_colour, Some(tokens.continuity[3]), "the grouped key also carries the actual Gap scene token"); + assert!(key.pattern); + assert!(h.app.session.continuity_details().iter().all(|row| row.verdict == solidcraft_engine::status_details::ContinuityVerdict::Sharp)); + } + } +} + +#[test] +fn details_tool_and_native_record_menu_each_keep_the_first_escape() { + let mut h = Harness::new(); + h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap(); + h.app.start("inspect.continuity"); + h.frames(3); + let owner = crate::viewport_status::identity(&h.app.session.status_items()[0]); + h.step(&json!({"click":{"handle":format!("viewport_status:{owner}:key")}})).unwrap(); + h.frames(3); + assert!(h.app.tool.is_some()); + h.step(&json!({"key":"Escape"})).unwrap(); + assert!(h.app.tool.is_none()); + assert!(h.app.viewport_status.open_key.is_some()); + assert!(!h.app.session.continuity.is_empty()); + h.step(&json!({"click":{"handle":"details:field:record"}})).unwrap(); + h.frames(3); + assert!(egui::Popup::is_any_open(&h.ctx)); + h.step(&json!({"key":"Escape"})).unwrap(); + assert!(!egui::Popup::is_any_open(&h.ctx)); + assert!(h.app.viewport_status.open_key.is_some()); + assert!(!h.app.session.continuity.is_empty()); +} + +#[test] +fn details_summary_owner_anchors_and_count_changes_release_stale_cards() { + let mut h = summary_three_effects(); + h.step(&json!({"click":{"handle":"viewport_status:summary"}})).unwrap(); + h.frames(3); + let effect = h.app.session.status_items().into_iter().find(|effect| effect.command == "inspect.section").unwrap(); + let owner = crate::viewport_status::identity(&effect); + h.step(&json!({"click":{"handle":format!("viewport_status:{owner}:key")}})).unwrap(); + h.frames(3); + let badge = crate::scenario::rect_of("viewport_status:summary").unwrap(); + let outline = crate::scenario::rect_of("viewport_status:row").unwrap(); + let card = crate::scenario::rect_of("viewport_status:card").unwrap(); + assert!(outline.contains_rect(badge), "interactive target {badge:?} inside visible owner outline {outline:?}"); + assert!( + (outline.left() - card.left()).abs() < 1.0 && card.top() > outline.bottom(), + "visible owner {outline:?}, target {badge:?}, card {card:?}" + ); + assert!(!egui::Popup::is_any_open(&h.ctx)); + h.app.run("inspect.continuity", json!({"clear":true})).unwrap(); + h.frames(3); + assert!(h.app.viewport_status.open_key.is_none()); + assert!(crate::scenario::rect_of("viewport_status:card").is_none()); + assert!(h.app.session.section.is_some() && h.app.session.analysis.is_some()); +} + #[test] fn viewport_status_specimens_clear_and_open_keys_without_engine_changes() { let mut h = Harness::new(); @@ -443,8 +869,8 @@ fn sketch_previews_selection_band_and_number_focus_follow_sketch_accent() { assert!(output.shapes.iter().any(|s| sketch_stroke(&s.shape, t.sketch_accent))); for sketch in [false, true] { for crossing in [false, true] { - let bx = BoxSel { rect: Rect::from_min_size(pos2(20.0, 20.0), vec2(100.0, 80.0)), crossing }; - let output = draw(&h.ctx, |ui| paint_box_selection(ui.painter(), bx, sketch)); + let bx = BoxSel { rect: Rect::from_min_size(pos2(20.0, 20.0), vec2(100.0, 80.0)), crossing, lasso: Vec::new() }; + let output = draw(&h.ctx, |ui| paint_box_selection(ui.painter(), &bx, sketch)); let color = if sketch { t.sketch_accent } else if crossing { @@ -721,6 +1147,7 @@ fn bottom_readouts_clear_floating_history_on_the_first_frame() { #[test] fn continuity_legend_clears_floating_history() { let mut h = Harness::new(); + h.app.preferences.viewport_status_details = false; h.app.ui.layout = LayoutMode::Floating; h.app.preferences.timeline_left = false; h.app.ui.show_timeline = true; @@ -1144,6 +1571,7 @@ fn selected_sketch_point_aliases_respect_owner_and_visibility() { #[test] fn live_analysis_status_uses_session_settings_and_declared_clear_without_history() { let mut h = Harness::new(); + h.app.preferences.viewport_status_details = false; h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap(); h.app.session.mark_saved(); let before = (h.app.session.doc.to_json(), h.app.session.undo.len()); @@ -1282,6 +1710,7 @@ fn analysis_status_ignores_invalid_layout_bounds_without_ending_the_effect() { #[test] fn section_status_formats_actual_geometry_in_design_units_without_flip_sign_changes() { let mut h = Harness::new(); + h.app.preferences.viewport_status_details = false; for (params, metric, imperial) in [ (json!({"plane":"XY","at":25.4,"hatch":false}), "Z 25.40 mm", "Z 1.000 in"), (json!({"plane":"XZ","at":-25.4,"flip":true}), "Y -25.40 mm · hatch", "Y -1.000 in · hatch"), @@ -1465,6 +1894,7 @@ fn analysis_status_escape_owner_survives_egui_layout_repasses() { fn analysis_status_row_and_key_stay_below_preferences_and_cannot_steal_its_click() { for window_first in [false, true] { let mut h = Harness::new(); + h.app.preferences.viewport_status_details = false; if window_first { h.call("ui.prefs", json!({"open":"Interface"})); } @@ -1969,6 +2399,85 @@ fn summary_native_menu_escape_is_close_only_then_newest_replacement_clears_and_u assert!(crate::scenario::rect_of("viewport_status:summary").is_none()); } +#[test] +fn summary_menu_yields_to_preferences_and_command_dialog_without_resuming_or_stealing_focus() { + use solidcraft_engine::status_items::StatusId; + fn contains_text(shape: &Shape, text: &str) -> bool { + match shape { + Shape::Text(shape) => shape.galley.text() == text, + Shape::Vec(shapes) => shapes.iter().any(|shape| contains_text(shape, text)), + _ => false, + } + } + for owner in ["preferences", "command", "stacked"] { + let mut h = summary_three_effects(); + let frames = (h.ctx.global_style().animation_time * 60.0).ceil() as usize + 2; + h.step(&json!({"click":{"handle":"viewport_status:summary"}})).unwrap(); + h.frames(frames); + assert!(egui::Popup::is_any_open(&h.ctx)); + assert!(crate::scenario::rect_of("menu:viewport_status_clear_all").is_some()); + let effects = h.app.session.status_items(); + let eligible = [StatusId::SurfaceAnalysis, StatusId::Continuity, StatusId::Section]; + let newest = h.app.session.newest_status_item(&eligible); + let document = serde_json::to_value(&*h.app.session.doc).unwrap(); + if owner != "preferences" { + h.app.start("solid.fillet"); + assert!(h.app.dialog.is_some()); + } + if owner != "command" { + h.call("ui.prefs", json!({"open":"Interface"})); + } + h.frames(frames); + assert!(!egui::Popup::is_any_open(&h.ctx), "{owner} dismisses the lower Summary popup"); + assert!(crate::scenario::rect_of("menu:viewport_status_clear_all").is_none()); + let heading = crate::i18n::tr("ui.viewport_status_summary_heading").to_uppercase(); + assert!(!h.shapes.iter().any(|shape| contains_text(&shape.shape, &heading)), "no Summary list paints over {owner}"); + if owner != "command" { + h.step(&json!({"click":{"handle":"prefs:search"}})).unwrap(); + let focus = h.ctx.memory(|memory| memory.focused()).expect("native click focuses Preferences search"); + h.frames(frames); + assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(focus), "Summary must not reclaim focus"); + assert!(!h.app.viewport_status.owns_focus(&h.ctx)); + } + h.step(&json!({"key":"Escape"})).unwrap(); + assert!(!h.app.prefs_window.open); + assert_eq!(h.app.dialog.is_some(), owner == "stacked", "one Escape closes only the highest owner"); + assert_eq!(h.app.session.status_items(), effects); + assert_eq!(h.app.session.newest_status_item(&eligible), newest); + assert_eq!(serde_json::to_value(&*h.app.session.doc).unwrap(), document); + assert!(!egui::Popup::is_any_open(&h.ctx), "a dismissed Summary must not reopen after the owner closes"); + if owner == "stacked" { + h.step(&json!({"key":"Escape"})).unwrap(); + assert!(h.app.dialog.is_none()); + assert_eq!(h.app.session.status_items(), effects); + } + h.step(&json!({"click":{"handle":"viewport_status:summary"}})).unwrap(); + h.frames(frames); + assert!(egui::Popup::is_any_open(&h.ctx), "a distinct native click can reopen Summary"); + assert_eq!(h.app.session.status_items(), effects); + } +} + +#[test] +fn summary_menu_keeps_tool_owned_row_clear_available() { + let mut h = summary_three_effects(); + h.app.run("inspect.continuity", json!({"clear":true})).unwrap(); + h.app.start("inspect.continuity"); + let frames = (h.ctx.global_style().animation_time * 60.0).ceil() as usize + 2; + h.frames(frames); + assert!(h.app.tool.is_some()); + assert_eq!(h.app.session.status_items().len(), 3); + h.step(&json!({"click":{"handle":"viewport_status:summary"}})).unwrap(); + h.frames(frames); + assert!(egui::Popup::is_any_open(&h.ctx), "a Tool is not a higher modal owner"); + let section = h.app.session.status_items().into_iter().find(|item| item.id == solidcraft_engine::status_items::StatusId::Section).unwrap(); + let section = crate::viewport_status::presentation(&h.app, §ion).unwrap(); + h.step(&json!({"click":{"handle":format!("viewport_status:{}:close", section.id)}})).unwrap(); + assert!(h.app.session.section.is_none()); + assert!(h.app.tool.is_some(), "row X retains the active Tool"); + assert!(h.app.session.analysis.is_some() && !h.app.session.continuity.is_empty()); +} + #[test] fn summary_popup_closes_on_preference_count_and_replacement_transitions() { for transition in ["disable", "count", "replace", "zero"] { diff --git a/crates/ui-egui/src/viewport_status.rs b/crates/ui-egui/src/viewport_status.rs index e0dcd957b8..45605adb58 100644 --- a/crates/ui-egui/src/viewport_status.rs +++ b/crates/ui-egui/src/viewport_status.rs @@ -59,11 +59,11 @@ pub(crate) fn section_setting(app: &SolveApp, origin: [f64; 3], normal: [f64; 3] Some(text) } -fn identity(effect: &Effect) -> String { +pub(crate) fn identity(effect: &Effect) -> String { format!("{:?}:{}", effect.id, effect.command) } -fn direction(value: [f64; 3]) -> String { +pub(crate) fn direction(value: [f64; 3]) -> String { for (axis, vector) in [("X", [1.0, 0.0, 0.0]), ("Y", [0.0, 1.0, 0.0]), ("Z", [0.0, 0.0, 1.0])] { if value.iter().zip(vector).all(|(a, b)| (*a - b).abs() < 1e-8) { return axis.into(); @@ -95,8 +95,8 @@ pub(crate) fn presentation(app: &SolveApp, effect: &Effect) -> Option bool { if repeat { return app.viewport_status.open_key.is_some() || has_effect(app); } - if let Some(open) = app.viewport_status.open_key.take() - && app.session.status_items().iter().any(|effect| eligible(app).contains(&effect.id) && effect.has_key && identity(effect) == open) + if let Some(open) = app.viewport_status.open_key.clone() + && app.session.status_items().iter().any(|effect| presentation(app, effect).is_some_and(|item| item.has_key && item.id == open)) { + app.viewport_status.close_details(); return true; } if let Some(effect) = app.session.newest_status_item(&eligible(app)) { @@ -129,10 +130,49 @@ pub(crate) fn escape(app: &mut SolveApp, repeat: bool) -> bool { /// Reconcile before shortcut routing so a vanished native popup cannot own Escape invisibly. pub(crate) fn reconcile(app: &mut SolveApp, ctx: &egui::Context) { + app.viewport_status.reconcile_session(ctx, app.session.uid); + // A visible native field must retain focus through a completed input frame before + // consuming the request; hidden Area measurement is not a focus acknowledgement. + if ui_kit::details_field_owns_focus(ctx) || ui_kit::details_control_owns_focus(ctx) { + app.viewport_status.focus_field = None; + } + if app.viewport_status.live_details != app.preferences.viewport_status_details && app.viewport_status.open_key.is_some() { + ui_kit::dismiss_details_popups(ctx); + if ui_kit::details_field_owns_focus(ctx) || ui_kit::details_control_owns_focus(ctx) { + ctx.memory_mut(|memory| { + if let Some(id) = memory.focused() { + memory.surrender_focus(id); + } + }); + } + app.viewport_status.open_key = None; + app.viewport_status.focus_field = None; + } + app.viewport_status.live_details = app.preferences.viewport_status_details; let items: Vec<_> = app.session.status_items().iter().filter_map(|effect| presentation(app, effect)).collect(); + if app.viewport_status.open_key.as_ref().is_some_and(|open| !items.iter().any(|item| item.has_key && &item.id == open)) { + ui_kit::dismiss_details_popups(ctx); + if ui_kit::details_field_owns_focus(ctx) || ui_kit::details_control_owns_focus(ctx) { + ctx.memory_mut(|memory| { + if let Some(id) = memory.focused() { + memory.surrender_focus(id); + } + }); + } + app.viewport_status.open_key = None; + app.viewport_status.focus_field = None; + } let members = if app.preferences.viewport_status_summary && items.len() >= 3 { items.into_iter().map(|item| item.id).collect() } else { Vec::new() }; app.viewport_status.reconcile_summary(ctx, members); + // The popup is a separate foreground host: the badge's Background layer cannot + // shield it. Tools deliberately remain eligible for their existing row actions. + let higher_owner = app.prefs_window.open + || app.dialog.is_some() + || app.keymap.open + || app.ui.palette_open + || ctx.memory(|memory| memory.top_modal_layer().is_some()); + app.viewport_status.suspend_summary(ctx, higher_owner); } pub(crate) fn show(app: &mut SolveApp, ctx: &egui::Context, viewport: egui::Rect) { @@ -142,7 +182,7 @@ pub(crate) fn show(app: &mut SolveApp, ctx: &egui::Context, viewport: egui::Rect return; } let effects = app.session.status_items(); - if !app.preferences.viewport_analysis_status { + if !app.preferences.viewport_analysis_status && !app.preferences.viewport_section_status { app.viewport_status.open_key = None; } let items: Vec<_> = effects.iter().filter_map(|effect| presentation(app, effect)).collect(); @@ -164,10 +204,35 @@ pub(crate) fn show(app: &mut SolveApp, ctx: &egui::Context, viewport: egui::Rect if !free.is_finite() || !free.is_positive() { return; } - let legend = crate::viewport::analysis_legend_data(app); - let key_contents = |ui: &mut egui::Ui, _: &str| { - if let Some((title, along, rows)) = &legend { - ui_kit::analysis_key(ui, title, along.as_deref(), rows); + let modern = app.preferences.viewport_status_details; + let descriptions: Vec<_> = effects + .iter() + .filter_map(|effect| { + presentation(app, effect).map(|item| { + let details = + if modern { crate::viewport_details::description(app, effect) } else { crate::viewport_details::legacy_description(app) }; + (item.id, details) + }) + }) + .collect(); + if let Some(field) = app.viewport_status.focus_field.as_deref() + && !descriptions.iter().any(|(owner, details)| { + Some(owner.as_str()) == app.viewport_status.open_key.as_deref() + && details.settings.iter().any(|setting| setting.id == field && setting.enabled) + }) + { + app.viewport_status.focus_field = None; + } + let focus = app.viewport_status.focus_field.clone(); + let session_uid = app.session.uid; + let mut edits = Vec::new(); + let key_contents = |ui: &mut egui::Ui, id: &str| { + if let Some((_, description)) = descriptions.iter().find(|(owner, _)| owner == id) { + ui.push_id(session_uid, |ui| { + edits.extend( + ui_kit::details(ui, id, description, free.height() * 0.6, focus.as_deref()).into_iter().map(|event| (id.to_owned(), event)), + ); + }); } }; let newest = app.session.newest_status_item(&eligible(app)).map(|effect| identity(&effect)); @@ -177,6 +242,9 @@ pub(crate) fn show(app: &mut SolveApp, ctx: &egui::Context, viewport: egui::Rect ui_kit::viewport_status_row(ctx, free, &items, &mut app.viewport_status, key_contents) }; dispatch(app, actions); + for (owner, event) in edits { + crate::viewport_details::dispatch(app, ctx, &owner, event); + } } /// Resolve requests against current represented effects, never a menu-open snapshot. @@ -197,7 +265,12 @@ pub(crate) fn dispatch(app: &mut SolveApp, actions: Vec) { } } StatusAction::Edit(id) => { - if effects.iter().any(|effect| effect.id == StatusId::Section && identity(effect) == id) { + if app.preferences.viewport_status_details { + if let Some(effect) = effects.iter().find(|effect| identity(effect) == id) { + app.viewport_status.focus_field = Some(crate::viewport_details::primary(effect).into()); + app.viewport_status.open_key = Some(id); + } + } else if effects.iter().any(|effect| effect.id == StatusId::Section && identity(effect) == id) { crate::dialogs::edit_section(app); } } diff --git a/crates/ui-egui/src/viewport_status_fixture.rs b/crates/ui-egui/src/viewport_status_fixture.rs index 92132dc4a2..fdf49ad4f9 100644 --- a/crates/ui-egui/src/viewport_status_fixture.rs +++ b/crates/ui-egui/src/viewport_status_fixture.rs @@ -6,12 +6,70 @@ use serde_json::{Value, json}; pub(crate) struct Fixture { items: Vec, presentation: StatusPresentation, + details_readout: bool, } fn item(id: &str, kind: StatusKind, label: &str, setting: String, has_key: bool) -> StatusItem { StatusItem { id: id.into(), kind, name: crate::i18n::tr(label).into(), setting, has_key, editable: false } } +fn specimen(id: &str, readout: bool) -> crate::ui_kit::Details { + use crate::ui_kit::{DetailFinding, DetailKey, DetailSetting, DetailValue, Details}; + let t = crate::theme::Tokens::get(); + let tr = |id| crate::i18n::tr(id).to_owned(); + let mut description = Details { caption: Some(tr("ui.details_fixture_caption")), ..Default::default() }; + description.key = t + .analysis + .draft + .into_iter() + .zip(["≥ 1.0°", "−1.0° … 1.0°", "≤ −1.0°"]) + .enumerate() + .map(|(index, (colour, label))| DetailKey { + numeric: true, + pattern_colour: None, + colour: crate::theme::srgb(colour), + pattern: index == 2, + label: label.into(), + value: String::new(), + }) + .collect(); + if id != "details" { + return description; + } + description.settings = vec![ + DetailSetting { + id: "axis".into(), + label: tr("ui.details_pull"), + value: DetailValue::Axis { value: [0.0, 0.0, 1.0], current: "Z".into() }, + enabled: true, + }, + DetailSetting { id: "angle".into(), label: tr("ui.details_angle"), value: DetailValue::Angle(1.0), enabled: true }, + DetailSetting { + id: "slider".into(), + label: tr("ui.details_stripes"), + value: DetailValue::Slider { value: 14.5, min: 1.0, max: 200.0 }, + enabled: true, + }, + DetailSetting { id: "check".into(), label: tr("ui.details_hatch"), value: DetailValue::Check(true), enabled: true }, + ]; + if readout { + description.readout = (0..9).map(|index| (tr("ui.details_fixture_readout"), format!("{index}.00 mm"))).collect(); + } else { + description.findings_title = tr("ui.details_fixture"); + description.findings = (0..9) + .map(|index| DetailFinding { + id: format!("specimen_{index}"), + label: tr("ui.details_fixture_problem"), + value: format!("{index}.00 mm"), + warning: true, + target: true, + }) + .collect(); + } + description.actions.push(("copy".into(), tr("ui.details_copy_list"))); + description +} + /// Presets are bounded, named specimens rather than arbitrary user-provided widget state. pub(crate) fn control(app: &mut crate::SolveApp, params: &Value) -> Result { let preset = params.get("preset").and_then(Value::as_str).ok_or("missing `preset`")?; @@ -36,9 +94,11 @@ pub(crate) fn control(app: &mut crate::SolveApp, params: &Value) -> Result vec![zebra(), result(), attention()], "many" => vec![zebra(), section(), result(), attention()], "key" => vec![item("draft", StatusKind::Mode, "ui.draft", "Z · 1°".into(), true)], - _ => return Err("preset must be off, mode, result, attention, row, kinds, many or key".into()), + "details_findings" | "details_readout" => vec![item("details", StatusKind::Result, "ui.details_fixture", String::new(), true)], + _ => return Err("unknown viewport status component specimen preset".into()), }; app.viewport_status_fixture.items = items; + app.viewport_status_fixture.details_readout = preset == "details_readout"; Ok(json!({"preset": preset, "count": app.viewport_status_fixture.items.len()})) } @@ -65,24 +125,10 @@ pub(crate) fn show(app: &mut crate::SolveApp, ctx: &egui::Context, viewport: egu return; } let fixture = &mut app.viewport_status_fixture; - let key_contents = |ui: &mut egui::Ui, _: &str| { - let t = crate::theme::Tokens::get(); - ui.label(crate::i18n::tr("cmd.inspect_draft")); - ui.label(egui::RichText::new(format!("{} (0.00, 0.00, 1.00)", crate::i18n::tr("ui.pull_direction"))).monospace().color(t.text_dim)); - for (index, (colour, label)) in t.analysis.draft.into_iter().zip(["≥ 1.0°", "−1.0° … 1.0°", "≤ −1.0°"]).enumerate() { - ui.horizontal(|ui| { - let (rect, _) = ui.allocate_exact_size(egui::vec2(t.space_12, t.space_12), egui::Sense::hover()); - ui.painter().rect_filled(rect, t.radius_sm, crate::theme::srgb(colour)); - if index == 2 && t.analysis.hatch { - let painter = ui.painter().with_clip_rect(rect); - for offset in [-t.space_8, -t.space_4, 0.0, t.space_4, t.space_8] { - let from = rect.left_bottom() + egui::vec2(offset, 0.0); - painter.line_segment([from, from + egui::vec2(rect.height(), -rect.height())], ui.visuals().selection.stroke); - } - } - ui.label(label); - }); - } + let readout = fixture.details_readout; + let key_contents = |ui: &mut egui::Ui, id: &str| { + let description = specimen(id, readout); + crate::ui_kit::details(ui, id, &description, free.height() * 0.6, None); }; // Deliberately use the oldest specimen as newest to exercise chronology/slot divergence. // This is explicit specimen metadata, never an engine activation claim. diff --git a/crates/ui-egui/src/workspace.rs b/crates/ui-egui/src/workspace.rs index 51663b7735..2ed4a389df 100644 --- a/crates/ui-egui/src/workspace.rs +++ b/crates/ui-egui/src/workspace.rs @@ -1050,8 +1050,8 @@ mod tests { assert!(crate::scenario::handle_at("tab:MODEL").is_none()); assert_eq!(h.app.ui.tab, "SURFACE", "the first tab still shown is selected"); - // Settings › Interface lists the standard tabs too. - h.call("ui.prefs", json!({"open":"Interface"})); + // Settings › Layout lists the standard tabs too. + h.call("ui.prefs", json!({"open":"Layout"})); crate::prefs::reveal_preference(&mut h, "prefs:toolbar_tab:MODEL"); h.step(&json!({"click":{"handle":"prefs:toolbar_tab:MODEL"}})).unwrap(); assert!(h.app.preferences.hidden_toolbar_tabs.is_empty()); @@ -1177,7 +1177,7 @@ mod tests { assert_eq!(visible_tabs(&h.app), ["SKETCH"]); assert_eq!(h.app.ui.tab, "SKETCH"); - h.call("ui.prefs", json!({"open":"Interface"})); + h.call("ui.prefs", json!({"open":"Layout"})); h.call("ui.prefs", json!({"button":"defaults"})); h.call("ui.prefs", json!({"button":"ok"})); assert!(h.app.preferences.hidden_toolbar_tabs.is_empty()); diff --git a/crates/ui-egui/tests/fixtures/theme-options.json b/crates/ui-egui/tests/fixtures/theme-options.json new file mode 100644 index 0000000000..a97d38bc83 --- /dev/null +++ b/crates/ui-egui/tests/fixtures/theme-options.json @@ -0,0 +1,658 @@ +{ + "light-0": { + "format": "solidcraft-theme/1", + "name": "Options light 0", + "base": "light", + "options": [] + }, + "light-1": { + "format": "solidcraft-theme/1", + "name": "Options light 1", + "base": "light", + "options": [ + { + "id": "opacity", + "type": "range", + "default": 100 + } + ] + }, + "light-3": { + "format": "solidcraft-theme/1", + "name": "Options light 3", + "base": "light", + "options": [ + { + "id": "opacity", + "type": "range", + "default": 100 + }, + { + "id": "warm", + "type": "bool", + "default": false, + "label": { + "en": "Warm accent" + }, + "section": "Theme controls" + }, + { + "id": "palette", + "type": "choice", + "default": "v1", + "label": { + "en": "Palette" + }, + "values": [ + { + "id": "v1", + "label": { + "en": "Soft" + } + }, + { + "id": "v2", + "label": { + "en": "Warm" + } + }, + { + "id": "v3", + "label": { + "en": "Cool" + } + } + ] + } + ] + }, + "light-6": { + "format": "solidcraft-theme/1", + "name": "Options light 6", + "base": "light", + "options": [ + { + "id": "opacity", + "type": "range", + "default": 100 + }, + { + "id": "warm", + "type": "bool", + "default": false, + "label": { + "en": "Warm accent" + }, + "section": "Theme controls" + }, + { + "id": "palette", + "type": "choice", + "default": "v1", + "label": { + "en": "Palette" + }, + "values": [ + { + "id": "v1", + "label": { + "en": "Soft" + } + }, + { + "id": "v2", + "label": { + "en": "Warm" + } + }, + { + "id": "v3", + "label": { + "en": "Cool" + } + } + ] + }, + { + "id": "radius", + "type": "range", + "default": 5, + "min": 0, + "max": 10, + "step": 0.5, + "unit": "px", + "label": { + "en": "Rounding" + } + }, + { + "id": "shadow", + "type": "bool", + "default": false, + "label": { + "en": "Shadow" + } + }, + { + "id": "details", + "type": "bool", + "default": false, + "label": { + "en": "Details" + } + } + ] + }, + "light-12": { + "format": "solidcraft-theme/1", + "name": "Options light 12", + "base": "light", + "options": [ + { + "id": "opacity", + "type": "range", + "default": 100 + }, + { + "id": "warm", + "type": "bool", + "default": false, + "label": { + "en": "Warm accent" + }, + "section": "Theme controls" + }, + { + "id": "palette", + "type": "choice", + "default": "v1", + "label": { + "en": "Palette" + }, + "values": [ + { + "id": "v1", + "label": { + "en": "Soft" + } + }, + { + "id": "v2", + "label": { + "en": "Warm" + } + }, + { + "id": "v3", + "label": { + "en": "Cool" + } + }, + { + "id": "v4", + "label": { + "en": "Bold" + } + } + ] + }, + { + "id": "radius", + "type": "range", + "default": 5, + "min": 0, + "max": 10, + "step": 0.5, + "unit": "px", + "label": { + "en": "Rounding" + } + }, + { + "id": "shadow", + "type": "bool", + "default": true, + "label": { + "en": "Shadow" + } + }, + { + "id": "softness", + "type": "range", + "default": 5, + "min": 0, + "max": 10, + "step": 0.5, + "unit": "px", + "label": { + "en": "Softness" + }, + "parent": "shadow" + }, + { + "id": "details", + "type": "bool", + "default": false, + "label": { + "en": "Details" + } + }, + { + "id": "pattern", + "type": "choice", + "default": "v1", + "label": { + "en": "Pattern" + }, + "values": [ + { + "id": "v1", + "label": { + "en": "Soft" + } + }, + { + "id": "v2", + "label": { + "en": "Warm" + } + }, + { + "id": "v3", + "label": { + "en": "Cool" + } + }, + { + "id": "v4", + "label": { + "en": "Bold" + } + }, + { + "id": "v5", + "label": { + "en": "Muted" + } + }, + { + "id": "v6", + "label": { + "en": "Plain" + } + } + ] + }, + { + "id": "grid_tint", + "type": "bool", + "default": false, + "label": { + "en": "Tint grid" + } + }, + { + "id": "strong_edges", + "type": "bool", + "default": false, + "label": { + "en": "Strong edges" + } + }, + { + "id": "compact", + "type": "bool", + "default": false, + "label": { + "en": "Compact" + } + }, + { + "id": "extra", + "type": "bool", + "default": false, + "label": { + "en": "Extra" + }, + "advanced": true, + "section": "Fine tuning" + }, + { + "id": "outline", + "type": "bool", + "default": false, + "label": { + "en": "Outline" + } + } + ] + }, + "dark-0": { + "format": "solidcraft-theme/1", + "name": "Options dark 0", + "base": "dark", + "options": [] + }, + "dark-1": { + "format": "solidcraft-theme/1", + "name": "Options dark 1", + "base": "dark", + "options": [ + { + "id": "opacity", + "type": "range", + "default": 100 + } + ] + }, + "dark-3": { + "format": "solidcraft-theme/1", + "name": "Options dark 3", + "base": "dark", + "options": [ + { + "id": "opacity", + "type": "range", + "default": 100 + }, + { + "id": "warm", + "type": "bool", + "default": false, + "label": { + "en": "Warm accent" + }, + "section": "Theme controls" + }, + { + "id": "palette", + "type": "choice", + "default": "v1", + "label": { + "en": "Palette" + }, + "values": [ + { + "id": "v1", + "label": { + "en": "Soft" + } + }, + { + "id": "v2", + "label": { + "en": "Warm" + } + }, + { + "id": "v3", + "label": { + "en": "Cool" + } + } + ] + } + ] + }, + "dark-6": { + "format": "solidcraft-theme/1", + "name": "Options dark 6", + "base": "dark", + "options": [ + { + "id": "opacity", + "type": "range", + "default": 100 + }, + { + "id": "warm", + "type": "bool", + "default": false, + "label": { + "en": "Warm accent" + }, + "section": "Theme controls" + }, + { + "id": "palette", + "type": "choice", + "default": "v1", + "label": { + "en": "Palette" + }, + "values": [ + { + "id": "v1", + "label": { + "en": "Soft" + } + }, + { + "id": "v2", + "label": { + "en": "Warm" + } + }, + { + "id": "v3", + "label": { + "en": "Cool" + } + } + ] + }, + { + "id": "radius", + "type": "range", + "default": 5, + "min": 0, + "max": 10, + "step": 0.5, + "unit": "px", + "label": { + "en": "Rounding" + } + }, + { + "id": "shadow", + "type": "bool", + "default": false, + "label": { + "en": "Shadow" + } + }, + { + "id": "details", + "type": "bool", + "default": false, + "label": { + "en": "Details" + } + } + ] + }, + "dark-12": { + "format": "solidcraft-theme/1", + "name": "Options dark 12", + "base": "dark", + "options": [ + { + "id": "opacity", + "type": "range", + "default": 100 + }, + { + "id": "warm", + "type": "bool", + "default": false, + "label": { + "en": "Warm accent" + }, + "section": "Theme controls" + }, + { + "id": "palette", + "type": "choice", + "default": "v1", + "label": { + "en": "Palette" + }, + "values": [ + { + "id": "v1", + "label": { + "en": "Soft" + } + }, + { + "id": "v2", + "label": { + "en": "Warm" + } + }, + { + "id": "v3", + "label": { + "en": "Cool" + } + }, + { + "id": "v4", + "label": { + "en": "Bold" + } + } + ] + }, + { + "id": "radius", + "type": "range", + "default": 5, + "min": 0, + "max": 10, + "step": 0.5, + "unit": "px", + "label": { + "en": "Rounding" + } + }, + { + "id": "shadow", + "type": "bool", + "default": true, + "label": { + "en": "Shadow" + } + }, + { + "id": "softness", + "type": "range", + "default": 5, + "min": 0, + "max": 10, + "step": 0.5, + "unit": "px", + "label": { + "en": "Softness" + }, + "parent": "shadow" + }, + { + "id": "details", + "type": "bool", + "default": false, + "label": { + "en": "Details" + } + }, + { + "id": "pattern", + "type": "choice", + "default": "v1", + "label": { + "en": "Pattern" + }, + "values": [ + { + "id": "v1", + "label": { + "en": "Soft" + } + }, + { + "id": "v2", + "label": { + "en": "Warm" + } + }, + { + "id": "v3", + "label": { + "en": "Cool" + } + }, + { + "id": "v4", + "label": { + "en": "Bold" + } + }, + { + "id": "v5", + "label": { + "en": "Muted" + } + }, + { + "id": "v6", + "label": { + "en": "Plain" + } + } + ] + }, + { + "id": "grid_tint", + "type": "bool", + "default": false, + "label": { + "en": "Tint grid" + } + }, + { + "id": "strong_edges", + "type": "bool", + "default": false, + "label": { + "en": "Strong edges" + } + }, + { + "id": "compact", + "type": "bool", + "default": false, + "label": { + "en": "Compact" + } + }, + { + "id": "extra", + "type": "bool", + "default": false, + "label": { + "en": "Extra" + }, + "advanced": true, + "section": "Fine tuning" + }, + { + "id": "outline", + "type": "bool", + "default": false, + "label": { + "en": "Outline" + } + } + ] + } +} diff --git a/crates/ui-egui/tests/fixtures/theme-tabs.json b/crates/ui-egui/tests/fixtures/theme-tabs.json new file mode 100644 index 0000000000..abe55890da --- /dev/null +++ b/crates/ui-egui/tests/fixtures/theme-tabs.json @@ -0,0 +1,34 @@ +{ + "dark": { + "format": "solidcraft-theme/1", + "name": "Tabs dark", + "base": "dark", + "colors": {"panel":"#18202a", "text":"#f5f7fa", "text_dim":"#dce3ec", "form_ink":"#f5f7fa", "dialog_label":"#f5f7fa"}, + "options": [ + {"id":"tab_colours", "type":"bool", "default":true}, + {"id":"tab_fade_ms", "type":"range", "default":240}, + {"id":"opacity", "type":"range", "default":100} + ], + "tabs": { + "MODEL": {"panel":"#18202a", "viewport_top":"#182430", "viewport_bottom":"#29394a"}, + "SKETCH": {"panel":"#123028", "viewport_top":"#103128", "viewport_bottom":"#204a3c"}, + "RENDER": {"panel":"#302039", "viewport_top":"#30203d", "viewport_bottom":"#493252"} + } + }, + "light": { + "format": "solidcraft-theme/1", + "name": "Tabs light", + "base": "light", + "colors": {"panel":"#fafbfc", "text":"#172333", "text_dim":"#334155", "form_ink":"#172333", "dialog_label":"#172333"}, + "options": [ + {"id":"tab_colours", "type":"bool", "default":true}, + {"id":"tab_fade_ms", "type":"range", "default":240}, + {"id":"opacity", "type":"range", "default":100} + ], + "tabs": { + "MODEL": {"panel":"#fafbfc", "viewport_top":"#e6f0fa", "viewport_bottom":"#ffffff"}, + "SKETCH": {"panel":"#e0f5e8", "viewport_top":"#cceddb", "viewport_bottom":"#f0fff7"}, + "RENDER": {"panel":"#f8e5f5", "viewport_top":"#ead1ef", "viewport_bottom":"#fff4ff"} + } + } +} diff --git a/crates/ui-egui/tests/scenarios/agent_chat_home_page.json b/crates/ui-egui/tests/scenarios/agent_chat_home_page.json index cf74cb2293..19128c3fcb 100644 --- a/crates/ui-egui/tests/scenarios/agent_chat_home_page.json +++ b/crates/ui-egui/tests/scenarios/agent_chat_home_page.json @@ -3,7 +3,7 @@ {"call": "ui.home", "params": {"open": true}}, {"expect": {"home": true}}, {"call": "ui.prefs", "params": {"open": "AI"}}, - {"expect": {"home": true, "prefs": {"dialog": "AI"}}}, + {"expect": {"home": true, "prefs": {"dialog": "Assistant"}}}, {"until": {"not_shown": "Looking for agents…"}}, {"expect": {"shown": "Add agent…"}}, {"click": {"handle": "prefs:ai_add"}}, diff --git a/crates/ui-egui/tests/scenarios/colour_vision_setting.json b/crates/ui-egui/tests/scenarios/colour_vision_setting.json index 5d3b47ca66..dab7d3ef57 100644 --- a/crates/ui-egui/tests/scenarios/colour_vision_setting.json +++ b/crates/ui-egui/tests/scenarios/colour_vision_setting.json @@ -2,23 +2,23 @@ {"note": "Colour vision lives on Interface: choices apply immediately; Cancel restores them, OK keeps them, and Interface defaults reset them. Page scrolling preserves the fixed window width."}, {"click": {"handle": "titlebar:preferences"}}, {"expect": {"prefs": {"dialog": "General", "color_vision": "off"}, "same_width": "prefs:window"}}, - {"widget": "Interface"}, + {"widget": "Appearance"}, {"call": "ui.scroll", "params": {"x": 1000, "y": 500, "delta": -5000}}, {"call": "ui.inspect", "params": {}}, - {"expect": {"shown": "Color vision", "prefs": {"dialog": "Interface"}, "same_width": "prefs:window"}}, + {"expect": {"shown": "Color vision", "prefs": {"dialog": "Appearance"}, "same_width": "prefs:window"}}, {"widget": "Deutan"}, {"expect": {"prefs": {"color_vision": "deutan"}, "same_width": "prefs:window"}}, {"widget": "Cancel"}, {"expect": {"prefs": {"dialog": null, "color_vision": "off"}}}, {"key": "Comma", "cmd": true}, - {"widget": "Interface"}, + {"widget": "Appearance"}, {"call": "ui.scroll", "params": {"x": 1000, "y": 500, "delta": -5000}}, {"call": "ui.inspect", "params": {}}, {"widget": "Tritan"}, {"widget": "OK"}, {"expect": {"prefs": {"dialog": null, "color_vision": "tritan"}}}, {"key": "Comma", "cmd": true}, - {"widget": "Interface"}, + {"widget": "Appearance"}, {"call": "ui.scroll", "params": {"x": 1000, "y": 500, "delta": -5000}}, {"call": "ui.inspect", "params": {}}, {"expect": {"shown": "Tritan", "same_width": "prefs:window"}}, diff --git a/crates/ui-egui/tests/scenarios/dialog_buttons_top.json b/crates/ui-egui/tests/scenarios/dialog_buttons_top.json index 68a01d46fc..23bf145a2b 100644 --- a/crates/ui-egui/tests/scenarios/dialog_buttons_top.json +++ b/crates/ui-egui/tests/scenarios/dialog_buttons_top.json @@ -3,7 +3,7 @@ {"call": "engine.execute", "params": {"command": "sketch.create", "params": {"plane": "XY", "name": "Sketch1"}}}, {"call": "engine.execute", "params": {"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [40, 30]}}}, {"call": "engine.execute", "params": {"command": "sketch.finish", "params": {}}}, - {"call": "ui.prefs", "params": {"open": "Interface"}}, + {"call": "ui.prefs", "params": {"open": "Layout"}}, {"widget": "Top", "near": "Dialog buttons"}, {"widget": "OK"}, {"expect": {"prefs": {"dialog": null, "dialog_buttons": "top"}}}, diff --git a/crates/ui-egui/tests/scenarios/preferences_search.json b/crates/ui-egui/tests/scenarios/preferences_search.json index e635dfc327..5c653fd3ff 100644 --- a/crates/ui-egui/tests/scenarios/preferences_search.json +++ b/crates/ui-egui/tests/scenarios/preferences_search.json @@ -2,12 +2,19 @@ {"note": "With Preferences open and nothing being edited, typing focuses Search preferences and the letters are the query. Enter opens that setting on its own page."}, {"click": {"handle": "titlebar:preferences"}}, {"expect": {"shown": "Restore Defaults", "prefs": {"dialog": "General"}}}, - {"text": "zo"}, + {"text": "Reverse zoom direction"}, {"frames": 3}, {"expect": {"shown": "Reverse zoom direction", "not_shown": "Units of new designs"}}, {"key": "Enter"}, {"frames": 2}, - {"expect": {"shown": "Reverse zoom direction", "prefs": {"dialog": "Navigation"}}}, + {"expect": {"shown": "Reverse zoom direction", "shown_handles": ["prefs:revealed"], "prefs": {"dialog": "Navigation"}}}, + {"call": "ui.prefs", "params": {"open": "General"}}, + {"text": "Direct interaction"}, + {"frames": 3}, + {"expect": {"shown": "Work > Tools"}}, + {"key": "Enter"}, + {"frames": 2}, + {"expect": {"shown": "Direct interaction", "prefs": {"dialog": "Tools"}}}, {"key": "Escape"}, {"expect": {"not_shown": "Restore Defaults", "prefs": {"dialog": null}}} ] diff --git a/crates/ui-egui/tests/scenarios/prefs_theme_files.json b/crates/ui-egui/tests/scenarios/prefs_theme_files.json index d93995b093..f9e4d5a571 100644 --- a/crates/ui-egui/tests/scenarios/prefs_theme_files.json +++ b/crates/ui-egui/tests/scenarios/prefs_theme_files.json @@ -2,7 +2,7 @@ {"note": "#2255: theme tiles offer the built-in themes; choosing one applies it immediately, and Cancel restores the previous theme."}, {"click": {"handle": "titlebar:preferences"}}, {"expect": {"prefs": {"dialog": "General", "theme": "dark", "dark": true}}}, - {"widget": "Interface"}, + {"widget": "Appearance"}, {"click": {"handle": "prefs:theme:light"}}, {"expect": {"prefs": {"theme": "light", "dark": false}, "shown_handles": ["prefs:theme"]}}, {"click": {"handle": "prefs:theme:gruvbox-dark"}}, @@ -11,9 +11,53 @@ {"expect": {"prefs": {"theme": "light", "dark": false}}}, {"widget": "Cancel"}, {"expect": {"prefs": {"dialog": null, "theme": "dark", "dark": true}}}, + {"note":"Gruvbox has two cards, each with Soft, Standard and Hard contrast. Old variant ids select the matching choice."}, + {"call":"ui.prefs","params":{"theme":"gruvbox-dark-hard","open":"Interface"}}, + {"expect":{"prefs":{"theme":"gruvbox-dark","theme_option_values":{"contrast":"hard"}},"theme_colors":{"panel":"#1d2021"}}}, + {"call":"ui.prefs","params":{"theme_options":{"gruvbox-dark":{"contrast":"soft"}}}}, + {"expect":{"prefs":{"theme_option_values":{"contrast":"soft"}},"theme_colors":{"panel":"#32302f"}}}, + {"call":"ui.prefs","params":{"theme":"gruvbox-light-hard"}}, + {"expect":{"prefs":{"theme":"gruvbox-light","theme_option_values":{"contrast":"hard"}},"theme_colors":{"panel":"#f9f5d7"}}}, + {"call":"ui.prefs","params":{"theme":"gruvbox-dark"}}, + {"expect":{"prefs":{"theme_option_values":{"contrast":"soft"}},"theme_colors":{"panel":"#32302f"}}}, + {"call":"ui.prefs","params":{"theme_options":{"gruvbox-dark":{"contrast":null}}}}, + {"expect":{"prefs":{"theme_option_values":{"contrast":"standard"}},"theme_colors":{"panel":"#282828"}}}, + {"call":"ui.prefs","params":{"theme_options":{"gruvbox-light":{}}}}, + {"call":"ui.prefs","params":{"button":"ok"}}, {"note": "Okibi (dark) and Akari (light) are built in by id."}, {"call": "ui.prefs", "params": {"theme": "akari"}}, {"expect": {"prefs": {"theme": "akari", "dark": false}}}, {"call": "ui.prefs", "params": {"theme": "okibi"}}, - {"expect": {"prefs": {"theme": "okibi", "dark": true}}} + {"expect": {"prefs": {"theme": "okibi", "dark": true}}}, + {"note":"#9806: per-theme option overrides apply through ui.prefs and keep authored export defaults."}, + {"call":"ui.prefs","params":{"theme_import":{"format":"solidcraft-theme/1","name":"Scenario Options","base":"dark","colors":{"panel":"#112233"},"options":[{"id":"enabled","type":"bool","default":false}],"overlays":[{"when":{"opt":"enabled","eq":true},"colors":{"panel":"#abcdef"}}]},"theme_options":{"scenario-options":{"enabled":true}}}}, + {"expect":{"prefs":{"theme":"scenario-options","theme_option_values":{"enabled":true},"theme_option_notices":[]},"theme_colors":{"panel":"#abcdef"}}}, + {"call":"ui.prefs","params":{"theme":"dark"}}, + {"expect":{"prefs":{"theme_option_values":{},"theme_option_notices":[]}}}, + {"call":"ui.prefs","params":{"theme":"scenario-options"}}, + {"expect":{"prefs":{"theme_option_values":{"enabled":true}},"theme_colors":{"panel":"#abcdef"}}}, + {"call":"ui.prefs","params":{"theme_options":{"scenario-options":{"enabled":false,"unknown":true}},"ui_scale":1.5},"fail":true}, + {"expect":{"prefs":{"theme_option_values":{"enabled":true},"ui_scale":1.0},"theme_colors":{"panel":"#abcdef"}}}, + {"call":"ui.prefs","params":{"theme_options":{"scenario-options":{"enabled":null}}}}, + {"expect":{"prefs":{"theme_option_values":{"enabled":false}},"theme_colors":{"panel":"#112233"}}}, + {"call":"ui.prefs","params":{"theme_options":{"scenario-options":{"enabled":true}}}}, + {"call":"ui.prefs","params":{"theme_options":{"scenario-options":{}}}}, + {"expect":{"prefs":{"theme_options":{},"theme_option_values":{"enabled":false}},"theme_colors":{"panel":"#112233"}}}, + {"note":"#9808: imported tab surfaces follow Model, Sketch and Render together; disabling them restores the exact base on every tab."}, + {"call":"ui.prefs","params":{"theme_import":{"format":"solidcraft-theme/1","name":"Scenario Tabs","base":"dark","colors":{"panel":"#18202a","viewport_top":"#182430","viewport_bottom":"#29394a"},"options":[{"id":"tab_colours","type":"bool","default":true},{"id":"tab_fade_ms","type":"range","default":0}],"tabs":{"SKETCH":{"panel":"#123028","viewport_top":"#103128","viewport_bottom":"#204a3c"},"RENDER":{"panel":"#302039","viewport_top":"#30203d","viewport_bottom":"#493252"}}}}}, + {"call":"ui.set","params":{"tab":"MODEL"}}, + {"expect":{"theme_colors":{"panel":"#18202a","viewport_top":"#182430","viewport_bottom":"#29394a"}}}, + {"call":"engine.execute","params":{"command":"sketch.create","params":{"plane":"XY"}}}, + {"expect":{"theme_colors":{"panel":"#123028","viewport_top":"#103128","viewport_bottom":"#204a3c"}}}, + {"call":"engine.execute","params":{"command":"sketch.finish","params":{}}}, + {"call":"ui.workspace","params":{"workspace":"render"}}, + {"expect":{"theme_colors":{"panel":"#302039","viewport_top":"#30203d","viewport_bottom":"#493252"}}}, + {"call":"ui.prefs","params":{"theme_options":{"scenario-tabs":{"tab_colours":false}}}}, + {"expect":{"theme_colors":{"panel":"#18202a","viewport_top":"#182430","viewport_bottom":"#29394a"}}}, + {"call":"ui.workspace","params":{"workspace":"design"}}, + {"call":"engine.execute","params":{"command":"sketch.create","params":{"plane":"XY"}}}, + {"expect":{"theme_colors":{"panel":"#18202a","viewport_top":"#182430","viewport_bottom":"#29394a"}}}, + {"call":"engine.execute","params":{"command":"sketch.finish","params":{}}}, + {"call":"ui.set","params":{"tab":"MODEL"}}, + {"expect":{"theme_colors":{"panel":"#18202a","viewport_top":"#182430","viewport_bottom":"#29394a"}}} ] diff --git a/crates/ui-egui/tests/scenarios/prefs_viewport_theme.json b/crates/ui-egui/tests/scenarios/prefs_viewport_theme.json index 6d150bafc6..ab63a0ab3c 100644 --- a/crates/ui-egui/tests/scenarios/prefs_viewport_theme.json +++ b/crates/ui-egui/tests/scenarios/prefs_viewport_theme.json @@ -1,7 +1,7 @@ [ {"note": "#4146: under the theme tiles, Viewport draws the 3D view in the Light or Dark theme's colours whatever the window uses; it applies at once, and Cancel puts it back."}, {"click": {"handle": "titlebar:preferences"}}, - {"widget": "Interface"}, + {"widget": "Appearance"}, {"expect": {"prefs": {"theme": "dark", "dark": true, "viewport_theme": "theme", "viewport_dark": true}}}, {"widget": "Light", "near": "Viewport"}, {"expect": {"prefs": {"theme": "dark", "dark": true, "viewport_theme": "light", "viewport_dark": false}}}, diff --git a/crates/ui-egui/tests/scenarios/s45_preferences.json b/crates/ui-egui/tests/scenarios/s45_preferences.json index 7f11e0c56c..9dc110b346 100644 --- a/crates/ui-egui/tests/scenarios/s45_preferences.json +++ b/crates/ui-egui/tests/scenarios/s45_preferences.json @@ -2,7 +2,7 @@ {"note": "#36: one Preferences dialog, from the gear at the top right (and Ctrl+,). Changes apply at once; Cancel puts them back, OK keeps them."}, {"click": {"handle": "titlebar:preferences"}}, {"expect": {"shown": "Restore Defaults", "prefs": {"dialog": "General", "theme": "dark", "dark": true}}}, - {"widget": "Interface"}, + {"widget": "Appearance"}, {"click": {"handle": "prefs:theme"}}, {"click": {"handle": "prefs:theme:light"}}, {"expect": {"prefs": {"theme": "light", "dark": false}}}, @@ -12,7 +12,7 @@ {"key": "Comma", "cmd": true}, {"expect": {"prefs": {"dialog": "General"}}}, {"widget": "Shortcuts"}, - {"expect": {"shown": "Reset All", "prefs": {"dialog": "Keyboard Shortcuts"}}}, + {"expect": {"shown": "Reset All", "prefs": {"dialog": "Shortcuts"}}}, {"widget": "Navigation"}, {"widget": "Fusion"}, {"widget": "Tinkercad"}, diff --git a/crates/ui-egui/tests/scenarios/s46_preferences_fixed_size.json b/crates/ui-egui/tests/scenarios/s46_preferences_fixed_size.json index 87a2316d13..a6ca40c8db 100644 --- a/crates/ui-egui/tests/scenarios/s46_preferences_fixed_size.json +++ b/crates/ui-egui/tests/scenarios/s46_preferences_fixed_size.json @@ -2,27 +2,62 @@ {"note": "#290: Preferences keeps one size on every page; descriptions wrap inside it, and the kit footer keeps Cancel / Apply / OK flush right inside its action inset."}, {"click": {"handle": "titlebar:preferences"}}, {"expect": {"prefs": {"dialog": "General"}, "same_width": "prefs:window", "right_edge": {"inner": "prefs:ok", "outer": "prefs:actions", "tol": 1}, "spans": {"inner": "prefs:footer", "outer": "prefs:window", "axis": "x", "tol": 12}}}, - {"widget": "Interface"}, - {"expect": {"prefs": {"dialog": "Interface"}, "same_width": "prefs:window", "right_edge": {"inner": "prefs:ok", "outer": "prefs:actions", "tol": 1}}}, + {"widget": "Appearance"}, + {"expect": {"prefs": {"dialog": "Appearance"}, "same_width": "prefs:window", "right_edge": {"inner": "prefs:ok", "outer": "prefs:actions", "tol": 1}}}, + {"widget": "Layout"}, + {"expect": {"prefs": {"dialog": "Layout"}, "same_width": "prefs:window"}}, + {"widget": "Tools"}, + {"expect": {"prefs": {"dialog": "Tools"}, "same_width": "prefs:window"}}, + {"widget": "3D mouse"}, + {"expect": {"prefs": {"dialog": "3D mouse"}, "same_width": "prefs:window"}}, {"widget": "Navigation"}, {"expect": {"prefs": {"dialog": "Navigation"}, "same_width": "prefs:window", "right_edge": {"inner": "prefs:ok", "outer": "prefs:actions", "tol": 1}}}, {"widget": "Fusion"}, {"widget": "SolidWorks"}, {"expect": {"prefs": {"nav": "solidworks"}, "same_width": "prefs:window"}}, {"widget": "Shortcuts"}, - {"expect": {"prefs": {"dialog": "Keyboard Shortcuts"}, "same_width": "prefs:window", "right_edge": {"inner": "prefs:ok", "outer": "prefs:actions", "tol": 1}}}, + {"expect": {"prefs": {"dialog": "Shortcuts"}, "same_width": "prefs:window", "right_edge": {"inner": "prefs:ok", "outer": "prefs:actions", "tol": 1}}}, {"widget": "Design"}, {"expect": {"prefs": {"dialog": "Design"}, "same_width": "prefs:window", "right_edge": {"inner": "prefs:ok", "outer": "prefs:actions", "tol": 1}}}, {"widget": "Graphics"}, {"expect": {"prefs": {"dialog": "Graphics"}, "same_width": "prefs:window", "right_edge": {"inner": "prefs:ok", "outer": "prefs:actions", "tol": 1}}}, - {"widget": "Sketch & selection"}, + {"widget": "Sketch"}, {"expect": {"prefs": {"dialog": "Sketch"}, "same_width": "prefs:window", "right_edge": {"inner": "prefs:ok", "outer": "prefs:actions", "tol": 1}}}, {"widget": "Assistant"}, - {"expect": {"prefs": {"dialog": "AI"}, "same_width": "prefs:window", "right_edge": {"inner": "prefs:ok", "outer": "prefs:actions", "tol": 1}}}, + {"expect": {"prefs": {"dialog": "Assistant"}, "same_width": "prefs:window", "right_edge": {"inner": "prefs:ok", "outer": "prefs:actions", "tol": 1}}}, {"widget": "Plugins"}, {"expect": {"prefs": {"dialog": "Plugins"}, "same_width": "prefs:window", "right_edge": {"inner": "prefs:ok", "outer": "prefs:actions", "tol": 1}}}, {"widget": "General"}, {"expect": {"prefs": {"dialog": "General"}, "same_width": "prefs:window", "right_edge": {"inner": "prefs:ok", "outer": "prefs:actions", "tol": 1}}}, + {"note": "Repeat the twelve-page structure in the light theme, retaining real legacy handles."}, + {"call": "ui.prefs", "params": {"theme": "light", "open": "General"}}, + {"call": "ui.prefs", "params": {"open": "Appearance"}}, + {"expect": {"prefs": {"dialog": "Appearance"}, "same_width": "prefs:window"}}, + {"call": "ui.prefs", "params": {"open": "Layout"}}, + {"expect": {"prefs": {"dialog": "Layout"}, "same_width": "prefs:window"}}, + {"call": "ui.prefs", "params": {"open": "Graphics"}}, + {"expect": {"prefs": {"dialog": "Graphics"}, "same_width": "prefs:window"}}, + {"call": "ui.prefs", "params": {"open": "Design"}}, + {"expect": {"prefs": {"dialog": "Design"}, "same_width": "prefs:window"}}, + {"call": "ui.prefs", "params": {"open": "Sketch"}}, + {"expect": {"prefs": {"dialog": "Sketch"}, "same_width": "prefs:window"}}, + {"call": "ui.prefs", "params": {"open": "Tools"}}, + {"expect": {"prefs": {"dialog": "Tools"}, "same_width": "prefs:window"}}, + {"call": "ui.prefs", "params": {"open": "Navigation"}}, + {"expect": {"prefs": {"dialog": "Navigation"}, "same_width": "prefs:window"}}, + {"call": "ui.prefs", "params": {"open": "3D mouse"}}, + {"expect": {"prefs": {"dialog": "3D mouse"}, "same_width": "prefs:window"}}, + {"call": "ui.prefs", "params": {"open": "Shortcuts"}}, + {"expect": {"prefs": {"dialog": "Shortcuts"}, "same_width": "prefs:window"}}, + {"call": "ui.prefs", "params": {"open": "Assistant"}}, + {"expect": {"prefs": {"dialog": "Assistant"}, "same_width": "prefs:window"}}, + {"call": "ui.prefs", "params": {"open": "Plugins"}}, + {"expect": {"prefs": {"dialog": "Plugins"}, "same_width": "prefs:window"}}, + {"click": {"handle": "prefs:page:Interface"}}, + {"expect": {"prefs": {"dialog": "Appearance"}}}, + {"call": "ui.prefs", "params": {"open": "Shortcuts"}}, + {"click": {"handle": "prefs:page:Keyboard Shortcuts"}}, + {"expect": {"prefs": {"dialog": "Shortcuts"}}}, {"widget": "Cancel"}, {"expect": {"prefs": {"dialog": null}}} ] diff --git a/crates/ui-egui/tests/scenarios/sketch_corner_window.json b/crates/ui-egui/tests/scenarios/sketch_corner_window.json index 339e626bbd..e42ce9c9fb 100644 --- a/crates/ui-egui/tests/scenarios/sketch_corner_window.json +++ b/crates/ui-egui/tests/scenarios/sketch_corner_window.json @@ -37,6 +37,26 @@ {"key": "Escape"}, {"expect": {"dialog": null, "errors": 0, "sketch_curves": {"own": 5}}}, + {"note": "Several corners at once: each picked corner is cut alike by one OK, as a fillet rounds several. The window used to refuse a second corner."}, + {"start": "sketch.chamfer.equal_distance"}, + {"click": {"world": [0, 0, 0]}}, + {"click": {"world": [40, 0, 0]}}, + {"click": {"handle": "dialog:Distance"}}, + {"key": "A", "cmd": true}, + {"text": "3"}, + {"widget": "OK"}, + {"expect": {"dialog": null, "errors": 0, "sketch_curves": {"own": 7}}}, + {"note": "Both cuts start 3 mm along each side from their corners, (0, 0) and (40, 0)."}, + {"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 5, "type"], "value": "line"}}}, + {"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 5, "start_at", 0], "value": 3}}}, + {"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 5, "end_at", 1], "value": 3}}}, + {"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 6, "type"], "value": "line"}}}, + {"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 6, "start_at", 0], "value": 37}}}, + {"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 6, "end_at", 1], "value": 3}}}, + {"note": "Undo takes both cuts back in one step."}, + {"key": "Z", "cmd": true}, + {"expect": {"sketch_curves": {"own": 5}}}, + {"note": "Fillet: the same window with a radius, on a second sketch."}, {"call": "engine.execute", "params": {"command": "sketch.finish", "params": {}}}, {"call": "engine.execute", "params": {"command": "sketch.create", "params": {"plane": "XZ"}}}, diff --git a/crates/ui-egui/tests/scenarios/view_confirm_corner.json b/crates/ui-egui/tests/scenarios/view_confirm_corner.json index 44899e8814..b8f45c7fed 100644 --- a/crates/ui-egui/tests/scenarios/view_confirm_corner.json +++ b/crates/ui-egui/tests/scenarios/view_confirm_corner.json @@ -19,13 +19,15 @@ {"click": {"handle": "view:confirm:ok"}}, {"until": {"dialog": null}}, {"expect": {"bodies": 1, "features": 2, "errors": 0}}, - {"note": "A new sketch, left with the cross: it goes, with what was drawn in it."}, + {"note": "A new sketch, left with the cross: like Esc, it first ends the line's chain, then puts the tool down, and only then the sketch goes, with what was drawn in it."}, {"start": "sketch.create"}, {"click": {"plane": "XY"}}, {"start": "sketch.line"}, {"click": {"sketch": [50, 0]}}, {"click": {"sketch": [70, 10]}}, - {"key": "Escape"}, + {"click": {"handle": "view:confirm:cancel"}}, + {"click": {"handle": "view:confirm:cancel"}}, + {"expect": {"sketching": true, "sketch_curves": {"own": 1}}}, {"click": {"handle": "view:confirm:cancel"}}, {"expect": {"sketching": false, "features": 2, "errors": 0}} ] diff --git a/crates/ui-egui/tests/scenarios/viewport_status_item.json b/crates/ui-egui/tests/scenarios/viewport_status_item.json index 0c61856121..ef37d7c647 100644 --- a/crates/ui-egui/tests/scenarios/viewport_status_item.json +++ b/crates/ui-egui/tests/scenarios/viewport_status_item.json @@ -376,5 +376,281 @@ {"frames": 3}, {"click": {"handle": "menu:viewport_status_clear_all"}}, {"frames": 3}, - {"call": "ui.status_fixture","params": {"preset": "off"}} + {"call": "ui.status_fixture","params": {"preset": "off"}}, + {"note":"Details component specimens are labelled illustrative data; real Draft, Zebra and Section states use their normal engine commands."}, + {"call":"ui.status_fixture","params":{"preset":"off"}}, + {"call":"ui.prefs","params":{"viewport_status_details":true}}, + {"call":"engine.execute","params":{"command":"solid.box","params":{"height":20,"length":30,"width":20}}}, + {"call":"ui.prefs","params":{"color_vision":"off","theme":"light"}}, + {"call":"ui.status_fixture","params":{"preset":"details_findings"}}, + {"frames":4}, + {"click":{"handle":"viewport_status:details:key"}}, + {"frames":4}, + {"expect":{"count":{"name":"details:findings_visible","value":6}}}, + {"shot":"viewport-status-details_findings-light-off"}, + {"click":{"handle":"viewport_status:details:close"}}, + {"call":"ui.status_fixture","params":{"preset":"details_readout"}}, + {"frames":4}, + {"click":{"handle":"viewport_status:details:key"}}, + {"frames":4}, + {"expect":{"count":{"name":"details:readout_visible","value":6}}}, + {"shot":"viewport-status-details_readout-light-off"}, + {"click":{"handle":"viewport_status:details:close"}}, + {"call":"ui.status_fixture","params":{"preset":"off"}}, + {"call":"engine.execute","params":{"command":"inspect.draft","params":{"angle":1,"pull":[0,0,1]}}}, + {"frames":4}, + {"click":{"handle":"viewport_status:SurfaceAnalysis:inspect.draft:key"}}, + {"frames":4}, + {"shot":"viewport-status-real-inspect-draft-light-off"}, + {"click":{"handle":"viewport_status:SurfaceAnalysis:inspect.draft:close"}}, + {"call":"engine.execute","params":{"command":"inspect.zebra","params":{"stripes":14.5}}}, + {"frames":4}, + {"click":{"handle":"viewport_status:SurfaceAnalysis:inspect.zebra:key"}}, + {"frames":4}, + {"shot":"viewport-status-real-inspect-zebra-light-off"}, + {"click":{"handle":"viewport_status:SurfaceAnalysis:inspect.zebra:close"}}, + {"call":"engine.execute","params":{"command":"inspect.section","params":{"hatch":true,"plane":"XY","offset":"20 mm"}}}, + {"frames":4}, + {"click":{"handle":"viewport_status:Section:inspect.section:key"}}, + {"frames":4}, + {"shot":"viewport-status-real-inspect-section-light-off"}, + {"click":{"handle":"viewport_status:Section:inspect.section:close"}}, + {"call":"ui.prefs","params":{"color_vision":"protan","theme":"light"}}, + {"call":"ui.status_fixture","params":{"preset":"details_findings"}}, + {"frames":4}, + {"click":{"handle":"viewport_status:details:key"}}, + {"frames":4}, + {"expect":{"count":{"name":"details:findings_visible","value":6}}}, + {"shot":"viewport-status-details_findings-light-protan"}, + {"click":{"handle":"viewport_status:details:close"}}, + {"call":"ui.status_fixture","params":{"preset":"details_readout"}}, + {"frames":4}, + {"click":{"handle":"viewport_status:details:key"}}, + {"frames":4}, + {"expect":{"count":{"name":"details:readout_visible","value":6}}}, + {"shot":"viewport-status-details_readout-light-protan"}, + {"click":{"handle":"viewport_status:details:close"}}, + {"call":"ui.status_fixture","params":{"preset":"off"}}, + {"call":"engine.execute","params":{"command":"inspect.draft","params":{"angle":1,"pull":[0,0,1]}}}, + {"frames":4}, + {"click":{"handle":"viewport_status:SurfaceAnalysis:inspect.draft:key"}}, + {"frames":4}, + {"shot":"viewport-status-real-inspect-draft-light-protan"}, + {"click":{"handle":"viewport_status:SurfaceAnalysis:inspect.draft:close"}}, + {"call":"engine.execute","params":{"command":"inspect.zebra","params":{"stripes":14.5}}}, + {"frames":4}, + {"click":{"handle":"viewport_status:SurfaceAnalysis:inspect.zebra:key"}}, + {"frames":4}, + {"shot":"viewport-status-real-inspect-zebra-light-protan"}, + {"click":{"handle":"viewport_status:SurfaceAnalysis:inspect.zebra:close"}}, + {"call":"engine.execute","params":{"command":"inspect.section","params":{"hatch":true,"plane":"XY","offset":"20 mm"}}}, + {"frames":4}, + {"click":{"handle":"viewport_status:Section:inspect.section:key"}}, + {"frames":4}, + {"shot":"viewport-status-real-inspect-section-light-protan"}, + {"click":{"handle":"viewport_status:Section:inspect.section:close"}}, + {"call":"ui.prefs","params":{"color_vision":"deutan","theme":"light"}}, + {"call":"ui.status_fixture","params":{"preset":"details_findings"}}, + {"frames":4}, + {"click":{"handle":"viewport_status:details:key"}}, + {"frames":4}, + {"expect":{"count":{"name":"details:findings_visible","value":6}}}, + {"shot":"viewport-status-details_findings-light-deutan"}, + {"click":{"handle":"viewport_status:details:close"}}, + {"call":"ui.status_fixture","params":{"preset":"details_readout"}}, + {"frames":4}, + {"click":{"handle":"viewport_status:details:key"}}, + {"frames":4}, + {"expect":{"count":{"name":"details:readout_visible","value":6}}}, + {"shot":"viewport-status-details_readout-light-deutan"}, + {"click":{"handle":"viewport_status:details:close"}}, + {"call":"ui.status_fixture","params":{"preset":"off"}}, + {"call":"engine.execute","params":{"command":"inspect.draft","params":{"angle":1,"pull":[0,0,1]}}}, + {"frames":4}, + {"click":{"handle":"viewport_status:SurfaceAnalysis:inspect.draft:key"}}, + {"frames":4}, + {"shot":"viewport-status-real-inspect-draft-light-deutan"}, + {"click":{"handle":"viewport_status:SurfaceAnalysis:inspect.draft:close"}}, + {"call":"engine.execute","params":{"command":"inspect.zebra","params":{"stripes":14.5}}}, + {"frames":4}, + {"click":{"handle":"viewport_status:SurfaceAnalysis:inspect.zebra:key"}}, + {"frames":4}, + {"shot":"viewport-status-real-inspect-zebra-light-deutan"}, + {"click":{"handle":"viewport_status:SurfaceAnalysis:inspect.zebra:close"}}, + {"call":"engine.execute","params":{"command":"inspect.section","params":{"hatch":true,"plane":"XY","offset":"20 mm"}}}, + {"frames":4}, + {"click":{"handle":"viewport_status:Section:inspect.section:key"}}, + {"frames":4}, + {"shot":"viewport-status-real-inspect-section-light-deutan"}, + {"click":{"handle":"viewport_status:Section:inspect.section:close"}}, + {"call":"ui.prefs","params":{"color_vision":"tritan","theme":"light"}}, + {"call":"ui.status_fixture","params":{"preset":"details_findings"}}, + {"frames":4}, + {"click":{"handle":"viewport_status:details:key"}}, + {"frames":4}, + {"expect":{"count":{"name":"details:findings_visible","value":6}}}, + {"shot":"viewport-status-details_findings-light-tritan"}, + {"click":{"handle":"viewport_status:details:close"}}, + {"call":"ui.status_fixture","params":{"preset":"details_readout"}}, + {"frames":4}, + {"click":{"handle":"viewport_status:details:key"}}, + {"frames":4}, + {"expect":{"count":{"name":"details:readout_visible","value":6}}}, + {"shot":"viewport-status-details_readout-light-tritan"}, + {"click":{"handle":"viewport_status:details:close"}}, + {"call":"ui.status_fixture","params":{"preset":"off"}}, + {"call":"engine.execute","params":{"command":"inspect.draft","params":{"angle":1,"pull":[0,0,1]}}}, + {"frames":4}, + {"click":{"handle":"viewport_status:SurfaceAnalysis:inspect.draft:key"}}, + {"frames":4}, + {"shot":"viewport-status-real-inspect-draft-light-tritan"}, + {"click":{"handle":"viewport_status:SurfaceAnalysis:inspect.draft:close"}}, + {"call":"engine.execute","params":{"command":"inspect.zebra","params":{"stripes":14.5}}}, + {"frames":4}, + {"click":{"handle":"viewport_status:SurfaceAnalysis:inspect.zebra:key"}}, + {"frames":4}, + {"shot":"viewport-status-real-inspect-zebra-light-tritan"}, + {"click":{"handle":"viewport_status:SurfaceAnalysis:inspect.zebra:close"}}, + {"call":"engine.execute","params":{"command":"inspect.section","params":{"hatch":true,"plane":"XY","offset":"20 mm"}}}, + {"frames":4}, + {"click":{"handle":"viewport_status:Section:inspect.section:key"}}, + {"frames":4}, + {"shot":"viewport-status-real-inspect-section-light-tritan"}, + {"click":{"handle":"viewport_status:Section:inspect.section:close"}}, + {"call":"ui.prefs","params":{"color_vision":"off","theme":"dark"}}, + {"call":"ui.status_fixture","params":{"preset":"details_findings"}}, + {"frames":4}, + {"click":{"handle":"viewport_status:details:key"}}, + {"frames":4}, + {"expect":{"count":{"name":"details:findings_visible","value":6}}}, + {"shot":"viewport-status-details_findings-dark-off"}, + {"click":{"handle":"viewport_status:details:close"}}, + {"call":"ui.status_fixture","params":{"preset":"details_readout"}}, + {"frames":4}, + {"click":{"handle":"viewport_status:details:key"}}, + {"frames":4}, + {"expect":{"count":{"name":"details:readout_visible","value":6}}}, + {"shot":"viewport-status-details_readout-dark-off"}, + {"click":{"handle":"viewport_status:details:close"}}, + {"call":"ui.status_fixture","params":{"preset":"off"}}, + {"call":"engine.execute","params":{"command":"inspect.draft","params":{"angle":1,"pull":[0,0,1]}}}, + {"frames":4}, + {"click":{"handle":"viewport_status:SurfaceAnalysis:inspect.draft:key"}}, + {"frames":4}, + {"shot":"viewport-status-real-inspect-draft-dark-off"}, + {"click":{"handle":"viewport_status:SurfaceAnalysis:inspect.draft:close"}}, + {"call":"engine.execute","params":{"command":"inspect.zebra","params":{"stripes":14.5}}}, + {"frames":4}, + {"click":{"handle":"viewport_status:SurfaceAnalysis:inspect.zebra:key"}}, + {"frames":4}, + {"shot":"viewport-status-real-inspect-zebra-dark-off"}, + {"click":{"handle":"viewport_status:SurfaceAnalysis:inspect.zebra:close"}}, + {"call":"engine.execute","params":{"command":"inspect.section","params":{"hatch":true,"plane":"XY","offset":"20 mm"}}}, + {"frames":4}, + {"click":{"handle":"viewport_status:Section:inspect.section:key"}}, + {"frames":4}, + {"shot":"viewport-status-real-inspect-section-dark-off"}, + {"click":{"handle":"viewport_status:Section:inspect.section:close"}}, + {"call":"ui.prefs","params":{"color_vision":"protan","theme":"dark"}}, + {"call":"ui.status_fixture","params":{"preset":"details_findings"}}, + {"frames":4}, + {"click":{"handle":"viewport_status:details:key"}}, + {"frames":4}, + {"expect":{"count":{"name":"details:findings_visible","value":6}}}, + {"shot":"viewport-status-details_findings-dark-protan"}, + {"click":{"handle":"viewport_status:details:close"}}, + {"call":"ui.status_fixture","params":{"preset":"details_readout"}}, + {"frames":4}, + {"click":{"handle":"viewport_status:details:key"}}, + {"frames":4}, + {"expect":{"count":{"name":"details:readout_visible","value":6}}}, + {"shot":"viewport-status-details_readout-dark-protan"}, + {"click":{"handle":"viewport_status:details:close"}}, + {"call":"ui.status_fixture","params":{"preset":"off"}}, + {"call":"engine.execute","params":{"command":"inspect.draft","params":{"angle":1,"pull":[0,0,1]}}}, + {"frames":4}, + {"click":{"handle":"viewport_status:SurfaceAnalysis:inspect.draft:key"}}, + {"frames":4}, + {"shot":"viewport-status-real-inspect-draft-dark-protan"}, + {"click":{"handle":"viewport_status:SurfaceAnalysis:inspect.draft:close"}}, + {"call":"engine.execute","params":{"command":"inspect.zebra","params":{"stripes":14.5}}}, + {"frames":4}, + {"click":{"handle":"viewport_status:SurfaceAnalysis:inspect.zebra:key"}}, + {"frames":4}, + {"shot":"viewport-status-real-inspect-zebra-dark-protan"}, + {"click":{"handle":"viewport_status:SurfaceAnalysis:inspect.zebra:close"}}, + {"call":"engine.execute","params":{"command":"inspect.section","params":{"hatch":true,"plane":"XY","offset":"20 mm"}}}, + {"frames":4}, + {"click":{"handle":"viewport_status:Section:inspect.section:key"}}, + {"frames":4}, + {"shot":"viewport-status-real-inspect-section-dark-protan"}, + {"click":{"handle":"viewport_status:Section:inspect.section:close"}}, + {"call":"ui.prefs","params":{"color_vision":"deutan","theme":"dark"}}, + {"call":"ui.status_fixture","params":{"preset":"details_findings"}}, + {"frames":4}, + {"click":{"handle":"viewport_status:details:key"}}, + {"frames":4}, + {"expect":{"count":{"name":"details:findings_visible","value":6}}}, + {"shot":"viewport-status-details_findings-dark-deutan"}, + {"click":{"handle":"viewport_status:details:close"}}, + {"call":"ui.status_fixture","params":{"preset":"details_readout"}}, + {"frames":4}, + {"click":{"handle":"viewport_status:details:key"}}, + {"frames":4}, + {"expect":{"count":{"name":"details:readout_visible","value":6}}}, + {"shot":"viewport-status-details_readout-dark-deutan"}, + {"click":{"handle":"viewport_status:details:close"}}, + {"call":"ui.status_fixture","params":{"preset":"off"}}, + {"call":"engine.execute","params":{"command":"inspect.draft","params":{"angle":1,"pull":[0,0,1]}}}, + {"frames":4}, + {"click":{"handle":"viewport_status:SurfaceAnalysis:inspect.draft:key"}}, + {"frames":4}, + {"shot":"viewport-status-real-inspect-draft-dark-deutan"}, + {"click":{"handle":"viewport_status:SurfaceAnalysis:inspect.draft:close"}}, + {"call":"engine.execute","params":{"command":"inspect.zebra","params":{"stripes":14.5}}}, + {"frames":4}, + {"click":{"handle":"viewport_status:SurfaceAnalysis:inspect.zebra:key"}}, + {"frames":4}, + {"shot":"viewport-status-real-inspect-zebra-dark-deutan"}, + {"click":{"handle":"viewport_status:SurfaceAnalysis:inspect.zebra:close"}}, + {"call":"engine.execute","params":{"command":"inspect.section","params":{"hatch":true,"plane":"XY","offset":"20 mm"}}}, + {"frames":4}, + {"click":{"handle":"viewport_status:Section:inspect.section:key"}}, + {"frames":4}, + {"shot":"viewport-status-real-inspect-section-dark-deutan"}, + {"click":{"handle":"viewport_status:Section:inspect.section:close"}}, + {"call":"ui.prefs","params":{"color_vision":"tritan","theme":"dark"}}, + {"call":"ui.status_fixture","params":{"preset":"details_findings"}}, + {"frames":4}, + {"click":{"handle":"viewport_status:details:key"}}, + {"frames":4}, + {"expect":{"count":{"name":"details:findings_visible","value":6}}}, + {"shot":"viewport-status-details_findings-dark-tritan"}, + {"click":{"handle":"viewport_status:details:close"}}, + {"call":"ui.status_fixture","params":{"preset":"details_readout"}}, + {"frames":4}, + {"click":{"handle":"viewport_status:details:key"}}, + {"frames":4}, + {"expect":{"count":{"name":"details:readout_visible","value":6}}}, + {"shot":"viewport-status-details_readout-dark-tritan"}, + {"click":{"handle":"viewport_status:details:close"}}, + {"call":"ui.status_fixture","params":{"preset":"off"}}, + {"call":"engine.execute","params":{"command":"inspect.draft","params":{"angle":1,"pull":[0,0,1]}}}, + {"frames":4}, + {"click":{"handle":"viewport_status:SurfaceAnalysis:inspect.draft:key"}}, + {"frames":4}, + {"shot":"viewport-status-real-inspect-draft-dark-tritan"}, + {"click":{"handle":"viewport_status:SurfaceAnalysis:inspect.draft:close"}}, + {"call":"engine.execute","params":{"command":"inspect.zebra","params":{"stripes":14.5}}}, + {"frames":4}, + {"click":{"handle":"viewport_status:SurfaceAnalysis:inspect.zebra:key"}}, + {"frames":4}, + {"shot":"viewport-status-real-inspect-zebra-dark-tritan"}, + {"click":{"handle":"viewport_status:SurfaceAnalysis:inspect.zebra:close"}}, + {"call":"engine.execute","params":{"command":"inspect.section","params":{"hatch":true,"plane":"XY","offset":"20 mm"}}}, + {"frames":4}, + {"click":{"handle":"viewport_status:Section:inspect.section:key"}}, + {"frames":4}, + {"shot":"viewport-status-real-inspect-section-dark-tritan"}, + {"click":{"handle":"viewport_status:Section:inspect.section:close"}} ] diff --git a/docs/agent-chat.md b/docs/agent-chat.md index 7341489c25..0dd8cbded4 100644 --- a/docs/agent-chat.md +++ b/docs/agent-chat.md @@ -133,11 +133,19 @@ clears the files waiting on the chips too. ## Typing while the agent works Each design tab has its own conversation, agent session, draft, attachments and queued messages. -A new tab starts with an empty chat. Switching away stops the current turn and keeps its +A new tab starts with an empty chat and a session of its own; a sign-in the chat confirmed in one +tab still counts in the others, so a new tab doesn't mark the agent as signed out. Switching away stops the current turn and keeps its completed changes in that design's undo history. Returning restores the conversation; queued messages wait for **Send**. Closing a design closes its conversation. Conversations are kept only while the tabs remain open. +When the agent itself creates a new design or opens a file, its conversation follows the new +design and the same turn continues. Its completed changes stay in each design's own undo +history. The original tab starts a fresh conversation if you return to it. Opening a file that +is already in another tab returns an error instead of displacing that tab's conversation; +select that tab yourself to continue there. Your own New, Open and tab switches still stop +the current turn. Stop also cancels a file the agent is still opening. + The input stays enabled during a turn. **Enter** queues the message: it shows in the conversation as a **Queued** bubble that you can **Edit** or remove (×) until it is sent. Several queued messages go as one prompt, in order, when the turn ends normally. @@ -157,7 +165,7 @@ messages go as one prompt, in order, when the turn ends normally. Preferences → AI → **Agents** lists every agent the chat can use, each with its kind (a program or an endpoint), version or model, whether it looks signed in, **Test**, **Default** (the one a new -conversation starts with), its own permission setting, and **Remove** for the ones you added. +conversation starts with), its own permission setting, and **Edit** and **Remove** for the ones you added. ### Found by themselves @@ -187,7 +195,12 @@ doesn't change the last known login state. | GitHub Copilot CLI, Qwen Code, goose | listed with their install command when missing | The panel lists agents that aren't installed with their install command and a **Copy** button. -**Refresh** looks again after you install one. **Test** starts an agent briefly, asks it for a +**Refresh** looks again after you install one. An agent whose program is there but fails its +`--version` check without printing a version (an install that didn't finish, such as an npm +install whose setup script didn't run, or a program built for another kind of computer) is +still listed, marked **does not start** with the system's reason. Connecting to it first says +why and gives its install command; reinstall it until `--version` works in a terminal, then +press **Refresh**. A check that runs out of time is not counted as a failure. **Test** starts an agent briefly, asks it for a session and ends it again. ### Flatpak @@ -252,7 +265,20 @@ Grok Build also supports `XAI_API_KEY` for users who choose API authentication. take: 300 s by default, from 30 s to an hour. A slow local model (a large model on modest hardware, or a long message with many tool results) may need more; raise it here or in the endpoint's entry under **Agents**, where a change applies from the next request. A request that - runs out of time ends the message with the limit it hit and where to raise it. + runs out of time ends the message with the limit it hit and where to raise it. **Reasoning + effort** is optional and empty by default: nothing is sent and the provider's own default + applies. When set, the value goes out as `reasoning_effort` on each `/chat/completions` + request; accepted values differ per provider and model (for example `low`, `high` or `max`). + A value you set is never replaced by SolidCraft: if the endpoint refuses it, the refusal is + shown instead of being retried with `none`. + +**Edit** on an agent you added opens the same dialog filled with its name and command, or its +protocol, endpoint, model and request timeout, and **Save** changes it in place: it keeps its id, +its permission setting and the tools you always allow for it. A stored key is never shown: leave +the key field empty to keep it, or type a new one to replace it. A saved key still only goes to +HTTPS or this computer, so moving an endpoint with a key to a plain `http://` address elsewhere is +refused. The running conversation takes a new request timeout at once; a new command, endpoint +or model applies the next time the agent connects. For Ollama, choosing a model checks its reported capabilities in the background. If its capabilities list lacks `tools`, a warning below the model picker explains that the model can diff --git a/docs/assembly-properties.md b/docs/assembly-properties.md index 0358b336c1..79d9f574ed 100644 --- a/docs/assembly-properties.md +++ b/docs/assembly-properties.md @@ -69,3 +69,32 @@ quality. Each body is tessellated once, however many times the assembly places i turns its tensor. A closed body whose mesh does not close has `inertia_g_mm2: null`, and so does the total. Open bodies have no volume, so they have no tensor either, and the total leaves them out. + +## Per occurrence and per rigid group + +`assembly.mass_properties` (no parameters) gives what a dynamics solver needs of each moving +part, measured as above, in kg, mm and kg·mm²: + +```json +{"command":"assembly.mass_properties","params":{}} +``` + +- `occurrences`: the design's own bodies (`occurrence: 0`) and then every occurrence, each with + its `frame` (origin and unit axes in world coordinates), its own bodies (material, density, + volume and mass), and `mass`: its bodies' total `mass_kg`, `center_of_mass_mm` (world) and + `center_of_mass_local_mm` (in its frame), and the tensor about that centre in world axes + (`inertia_world_kg_mm2`) and in its frame's axes (`inertia_local_kg_mm2`). Only the bodies the + occurrence's component owns count, not those of occurrences nested in it. +- `groups`: occurrences that move as one body, each with the occurrences it holds, the same + `mass` totals (combined with the parallel-axis theorem) in the frame of its lowest + occurrence (`frame_occurrence`; 0 is the world), and `fixed` when it holds the design itself + or a grounded occurrence. An unsuppressed rigid joint (Rigid Group makes them) puts its two + occurrences in one group, and a nested occurrence that no joint places goes with the + occurrence it sits in, since the joint solver leaves it where its parent puts it. +- `warnings`: open bodies (no mass), closed bodies whose mesh does not close (`inertia_*: null`), + scaled placements (no tensor in their own axes) and subassemblies placed more than once (the + occurrences inside them are listed at the first placement, where joints place them). + +A body without a material assignment has the default material's density, as Properties shows. +Pending occurrence moves count. Rust callers get the same numbers, typed, from +`Session::mass_properties()`. diff --git a/docs/book/src/dev/commands.md b/docs/book/src/dev/commands.md index 505f521806..310f17387c 100644 --- a/docs/book/src/dev/commands.md +++ b/docs/book/src/dev/commands.md @@ -55,5 +55,5 @@ ending in the same word (`file.open`: did you mean `doc.open`?). (English, every catalog in `COMPLETE` in `crates/ui-egui/src/i18n.rs`, which a test requires, and any other language you can write) and the id to `DOCUMENTED` in `crates/ui-egui/src/command_help.rs`. The test - `every_toolbar_command_has_help_or_is_on_the_backlog` fails for a ribbon command without help; - the older ones on its `BACKLOG` only shrink (#2758). + `every_toolbar_command_has_help` fails for a ribbon command without help + (#2758). diff --git a/docs/book/src/guide/assemblies.md b/docs/book/src/guide/assemblies.md index 06b80eca79..59a5a82f96 100644 --- a/docs/book/src/guide/assemblies.md +++ b/docs/book/src/guide/assemblies.md @@ -259,7 +259,9 @@ it where it is), and it moves. `joint.drive` answers the joint's values as `joint.list` gives them, in degrees and millimetres with their units and limits, and `moved` says whether any part moved. A joint's origins are mated by default: B's z axis is turned against A's, as two faces meet. `flip: true` lines them up -instead, so two origins given at one point with the same z stay as they are. Without an +instead, so two origins given at one point with the same z and x stay as they are. A point origin +without `x` takes its x from z alone, so a part turned about z turns back when it is joined: give +`x` on both origins, or use `joint.as_built`, to keep it where it is. Without an `angle`, a new joint (other than a relation, which starts where its parts are) takes no turn or a half turn about its axis, whichever turns B less: two faces that already meet, a plate under a panel, stay as they are instead of B spinning about their shared normal. For scripts that drive many steps, `quiet: true` leaves the per-part @@ -383,15 +385,17 @@ Mesh** would. The part's solid bodies come in as they sit in that design, and they are kept in the assembly, so it opens even without the part file. The link is stored relative to the assembly's folder once the assembly is saved. After the part file changes, **Update Links** (`component.update_links`) reads it again. -An assembly whose tab comes back to the front does that by itself for the parts whose files +An assembly that opens, or whose tab comes back to the front, does that by itself for the parts whose files changed since it last read them, whether the part was saved in another tab, another window or by -a checkout. A part that cannot be read is tried again the next time, and holds up no other. +a checkout. The parts it reads come in as one step, so one **Undo** takes them all back. A part +that cannot be read keeps its bodies, is tried again the next time, and holds up no other. **Update changed linked designs when a tab comes back** (Preferences, Design, `update_changed_links`) turns this off. To edit the source, right-click a linked component or occurrence in the Browser and choose **Open Linked Design**. It opens the part in another tab and reports if its file is missing. **Break Link** (`component.break_link`) keeps the bodies and forgets the file, and -`component.links` lists the links and whether their files are found. A linked component holds +`component.links` lists the links, whether their files are found, and whether they changed since +the assembly last read them (`changed`). A linked component holds the part's bodies only: its timeline, parameters and sheet-metal data (flat pattern, bend table) stay in the part's own design. @@ -465,6 +469,8 @@ description; **Change Part Size** renumbers them, keeping a field you changed yo under it as `children` (items 1.1, 1.2…, with `each` per bolt and `count` in all) instead of rows of their own. Bolts with different contents (one with a nut, one without) are different rows. `total_occurrences` counts the children too, so it is the same either way. + For an inseparable part, only its purchased descendants become children; a purchased part + still has no children listed. Parts without a part number are not grouped this way. - `component` lists one subassembly's parts instead of the design's. A component's **bill of materials type** (`bom_type`, a part property set with diff --git a/docs/book/src/guide/equation-curves.md b/docs/book/src/guide/equation-curves.md index ebcbf4d93c..5c7f363337 100644 --- a/docs/book/src/guide/equation-curves.md +++ b/docs/book/src/guide/equation-curves.md @@ -89,6 +89,24 @@ makes as you unwind it from the circle. The sides of most gear teeth have this s Change `r` in the Parameters dialog and the curve redraws itself to match: the rule is kept, not just the shape. +## A variable with its range's unit + +Normally the variable is a plain number, even when **Start** and **End** are lengths. That stops a +rule from mixing it with a length parameter: with `a` a length, `a ^ 2 / (4 * t)` comes out as a +length squared. + +Tick **Variable has the range's unit** and the variable is a length, so the rule reads as the +maths does: + +| Rule in **x(t)** | Rule in **y(t)** | Start | End | +|---|---|---|---| +| `t` | `a ^ 2 / (4 * t)` | `a` | `3 * a` | + +**Start** and **End** must then both be lengths, or one a length and the other a plain number, +which is in your design's unit (`0` to `a` starts at 0). A length and an angle are refused: they +have no unit in common. A rule that needs a plain number divides the variable by a length, such +as `sin(t / a)`. + ## From curve to part An equation curve is an ordinary sketch curve, so you can build with it: diff --git a/docs/book/src/guide/sketching.md b/docs/book/src/guide/sketching.md index b01ea3543f..275fa56d62 100644 --- a/docs/book/src/guide/sketching.md +++ b/docs/book/src/guide/sketching.md @@ -12,6 +12,16 @@ an edited one goes back to how it was, as one step that Undo brings back. Opening a sketch turns the view to face it; to keep the view as it is and face the sketch only with **Look At**, turn off Preferences › Sketch › **Turn to face a sketch when it opens**. When the sketch ends, the view stays where it is by default. Preferences › Sketch › **When a sketch ends** changes that: **Return to the previous view** goes back to the view from before the sketch, and **Return unless rotated** goes back only when you panned or zoomed the sketch view but didn't turn it. +In an assembly a new sketch belongs to the active component and is listed in its Sketches +folder. Turn on Preferences › Sketch › **Sketching on a part's face makes that part active** to +keep sketches with the part they are drawn on: a sketch started on a face of another component's +body first activates that component, at the placement you picked, and is made there, as if you had +activated the part yourself. The part stays active afterwards, so an extrude or cut made from the +sketch lands in the same part; activate the design or another part again as usual. Off (the +default), the sketch belongs to the active component. In scripts the same is +`sketch.create {"plane": {"face": …}, "activate_part": true}`; the reply's `activated` names the +component and placement it activated. + In scripts, `XY`, `XZ`, `YZ` and their `Top`, `Front`, `Right` aliases name origin planes. Preferences › Interface › **Origin plane names** shows them as Top, Front and Right in the browser, dialogs and lists; scripts and saved designs keep XY, XZ and YZ. @@ -33,8 +43,11 @@ Hover over a circle or arc centre, or the midpoint of a straight sketch edge, th horizontally or vertically to see a dotted alignment guide. The guide connects that reference to the cursor's projection on the sketch axis, within the viewport; it disappears when you move away from the axis. SolidCraft keeps the two most recent of these references and can -draw both guides. One guide on its own does not move the cursor, and endpoints are ordinary -point snaps rather than remembered references. Preferences › Sketch › **Auto snap to recent +draw both guides. One guide on its own does not move the cursor. While a drawing tool (Line, +Rectangle, Circle, Arc, Polygon, Slot, Point…) waits for a point, any sketch point is a +reference too: the end of a line or arc, a rectangle's corner, or a free point. Preferences › +Sketch › **Alignment guides from end points and corners while drawing** turns that off and +keeps only centres and midpoints. Preferences › Sketch › **Auto snap to recent centers and midpoints** is off until you turn it on. With it on, a drawing tool snaps the cursor where the horizontal through one reference meets the vertical through the other. On a rectangle, the top midpoint and a side midpoint then meet at the rectangle's centre, so that @@ -57,7 +70,9 @@ fit-point and control-point splines, conics, points and text. **Construction** ( into reference geometry that doesn't form profiles. Modify tools include trim (T), extend, break, fillet, chamfer, offset (O), mirror, patterns, move/copy and scale. With trim on, click a piece to remove it, or press and drag a path across several pieces: the ones it crosses light up -and all go when you let go, in one undo step (`sketch.trim.path`). +and all go when you let go, in one undo step (`sketch.trim.path`). Fillet and chamfer take +several corners at once: pick the corners, set the size once, and every corner is rounded or +cut alike in one undo step. Line draws a chain: each click adds a segment from the last point, and a right-click or Esc ends the chain. To draw one separate line with a press, a drag and the release instead, turn on @@ -65,6 +80,13 @@ ends the chain. To draw one separate line with a press, a drag and the release i where you let go, snapping and lining up as clicks do, and ends there; clicks still draw a chain, and a drag shorter than about 24 points is still a click. +To round corners without changing tool, turn on **Preferences › Sketch › Rest on a line's last +point to switch to a tangent arc**. With a segment drawn, move the pointer away from the chain's +last point, bring it back and hold it still for about a third of a second: a label next to the +pointer says Tangent Arc, and the next click ends an arc tangent to the last segment. The same +gesture goes back to lines. Arcs and lines join into one chain, each click is one undo step, and a +right-click or Esc ends the chain as usual. + Tools with several variants (line and midpoint line; two-point, three-point, centre and rounded rectangle; the circle, arc, slot, polygon and spline variants) share one toolbar button with a drop-down. The button and the family's shortcut start the variant you picked last, so a @@ -419,6 +441,9 @@ Measure shows the currently selected distance (centre, minimum or maximum) and s distances on the canvas. The component dimensions follow the world axes and remain visible until the Measure dialog closes. To hide these extra lines and labels, turn off **Preferences > Graphics > Show X/Y/Z distances**. The choice is saved between runs. +Drag the distance or an X, Y or Z label to move it away from the part; a moved label keeps a +line to what it measures. Double-click a label to put it back. Measuring other items starts the +labels in their own places again. ### Reviewing mirror, pattern, and offset results diff --git a/docs/book/src/guide/ui-tour.md b/docs/book/src/guide/ui-tour.md index db0225189b..20f876f23a 100644 --- a/docs/book/src/guide/ui-tour.md +++ b/docs/book/src/guide/ui-tour.md @@ -6,7 +6,10 @@ the File menu (New, Open, Save, Save As, Export…), undo and redo, the Home tab (the start page) and one tab per open design (each with its own undo history, selection and camera; a dot marks unsaved changes). On the right: Report a bug, Help (?), Preferences (which hold the theme) and the - window's buttons. + window's buttons. On Linux, to have your desktop's own title bar and window buttons instead, set + `SOLIDCRAFT_TITLEBAR=system` or put `"titlebar": "system"` in `graphics.json` in the settings + folder (see [Troubleshooting](troubleshooting.md#reset-the-settings)) and restart; `app` (the + default) puts the application bar back. - **Toolbar.** The workspace switcher (DESIGN) sits on the left. The tabs (MODEL, SURFACE, ASSEMBLE, INSERT, INSPECT, MANAGE, VIEW and SHEET METAL) hold sections of commands. SKETCH appears while editing a sketch; SHEET METAL is always there and is highlighted for the active sheet body. Both select automatically @@ -22,7 +25,7 @@ - **Browser** (left). The design as a tree: document settings and units, named views, the origin, bodies, sketches, construction planes, canvases, joints and components. The eye at the row's right end shows or hides an item (**Preferences › Interface › Eye next to the fold arrow** - puts it back beside the fold arrow), a component's radio button makes it the active component, + puts it back beside the fold arrow; press an eye and drag up or down over other eyes to show or hide them all at once, as one undo step), a component's radio button makes it the active component, and right-clicking any row gives that item's menu. Right-click the design or a component and choose **Expand All** or **Collapse All** to open or close every folder under that row (the design row stays visible when the whole tree is collapsed). Rows drag into groups and other components. **Preferences › @@ -121,7 +124,8 @@ wide as that column; the column comes back as it was when the dialog closes. Interface › **Dialog buttons** › **Top** puts OK, Cancel and Apply in the dialog's title row, right of the title, where they stay while sections open and close; - Cancel then also takes the place of the close button. Enter and Esc work as before. + Cancel then also takes the place of the close button. Enter and Esc work as before. The Sketch + panel's Finish Sketch, Look at and Slice move up under its title, above the options. - **Timeline** (bottom). Every feature in order. Double-click to edit, drag the marker to roll back, and drag features to reorder them. A sketch's icon, here and in the browser, takes the colour of its state: the sketch colour while it is under constrained, the fully constrained @@ -292,6 +296,176 @@ Command icons use the shared SVG palette. Imported themes are kept with your preferences, so they work in the browser app too. Colour vision modes still apply on top of any theme. +#### Theme-file options and overlays + +Theme authors can add `options` and `overlays` to `solidcraft-theme/1`. Offered options appear +below the theme cards in Interface, with the theme's name. Switches, sliders, segmented choices +and dropdowns apply immediately and are saved per theme, including a followed theme file. +**Reset** appears when an offered value differs from its default and restores that theme's +defaults through the same preference update path. Cancel retains its existing behaviour of +restoring the Preferences window's opening snapshot. Themes with no offered options add no +options block or badge. Existing theme files need no changes or appearance changes. + +Options flow left to right into two columns. A parent and its child occupy one cell; turning +the parent off disables its child without forgetting the child's value. Choices with four or +five values and long labels take a full row; choices with more than five values use a dropdown. +The first ten non-advanced top-level entries appear before **More · N**; advanced entries and +overflow always appear inside it. Section labels appear only above six top-level entries. +Help is available on hover. A registered behaviour unavailable on the current device stays +disabled in its place with a reason; unimplemented standard behaviours and their children +are absent. Theme cards show an Options badge only when the theme offers controls. + +**Export…** preserves the authored definitions and overlays for a theme that declares them. +Older themes still export a complete token snapshot. The resolver API clamps numeric values; +invalid saved preference values instead fall back to the theme's defaults, retaining the raw +saved values for compatibility. + +For example, add these two keys alongside a theme's `colors`: + +```json +{ + "options": [ + { + "id": "warm_accent", + "type": "bool", + "default": true, + "label": {"en": "Warm accent"}, + "help": {"en": "Use a warmer accent colour."} + }, + { + "id": "rounding", + "type": "range", + "default": 4, + "min": 0, + "max": 12, + "step": 1, + "unit": "pt", + "parent": "warm_accent" + } + ], + "overlays": [ + { + "when": {"opt": "warm_accent", "eq": true}, + "colors": {"accent": "#cc7744"} + }, + { + "when": {"opt": "rounding", "gte": 4, "lte": 12}, + "radii": {"control_radius": 8} + } + ] +} +``` + +A file can declare up to 16 options. Each `id` is unique and matches +`[a-z][a-z0-9_]{0,31}`. Every option has a `type` and a `default` (opacity may omit its +default to use 100): + +- `bool`: the default is `true` or `false`. +- `choice`: `values` contains 2 to 12 entries, each with a unique `id` in the same syntax + and a localized `label` map, for example `{"id":"warm","label":{"en":"Warm"}}`. + The default names one of those ids. +- `range`: finite numeric `min`, `max` and positive `step` are required, with `min` no larger + than `max` and the default inside those bounds. Optional `unit` has at most 4 characters. + Numbers must fit a finite 32-bit float. Saved values are clamped to the bounds; `step` + is an increment for future controls, not a rounding rule. + +Optional `label` and `help` maps use language codes as keys, with `en` as the fallback. +Labels have at most 40 characters and help strings at most 160, per language. Each map +supports up to 64 language codes, each at most 32 ASCII letters, digits, hyphens or underscores. +These texts belong to the theme and need no application catalog entries. `advanced` is an +optional boolean; `section` is an optional string of at most 24 characters. `parent` names +a declared boolean option, with at most one child per parent and no cycles. While the +parent is off, overlays referring to the child are inactive; the child's value is retained. + +Four ids are reserved for definitions owned by the app: + +| Id | Type | Bounds | Unit | Parent | +|---|---|---|---|---| +| `opacity` | range | 40–100 | `%` | — | +| `blur` | range | 0–40 | `px` | — | +| `tab_colours` | bool | — | — | — | +| `tab_fade_ms` | range | 0–600 | `ms` | `tab_colours` | + +A theme declares these with their required type and a default; it may also narrow a range's +`min` and `max`. The default is clamped to those bounds. Other fields are ignored, including +presentation text, `step` (fixed at 20 for `tab_fade_ms`, 1 for other standard ranges), unit and parent. A wrong type or +bounds outside the standard range are import errors. Declaring `tab_fade_ms` also requires +declaring its boolean parent, `tab_colours`. Custom options cannot redefine reserved ids. +`opacity`, `tab_colours` and `tab_fade_ms` have registered standard behaviours. `blur` remains +absent from the list until its behaviour is registered. +The resolver exposes declared standard options in the order `opacity`, `blur`, `tab_colours`, +each immediately followed by its child, then custom options in declared order. + +Opacity scales only these floating surface fill tokens: `panel`, `panel_header`, `dialog_bg`, +`dialog_body`, `dialog_footer`, `overlay` and `nav_tip`. Scaling is applied after matching +overlays. All other tokens, including text, icons, borders, controls, viewport and document +colours, retain their resolved values. At 100%, with the implicit 100% default, or without +offered opacity, every token is exactly the original resolved value. The supported range is +40–100%; the theme may narrow it. To keep normal text at 4.5:1 against a panel over black and +white viewport extremes, each fill's actual alpha rises as necessary. This also protects +against background overrides and busy models. A theme whose authored foreground and opaque +fill already fail contrast receives full alpha; opacity never rewrites the theme's text colours. + +Imported themes can also declare `tabs`, using exact ribbon registry names. Only `panel`, +`viewport_top` and `viewport_bottom` are accepted; an unknown tab or any other token is an +import error. Tabs without an entry keep the base surfaces. Existing tab accent fills, inks +and tints keep their own tokens. For example, save this original theme as a JSON file and import it: + +```json +{ + "format": "solidcraft-theme/1", + "name": "Garden tabs", + "base": "dark", + "options": [ + {"id": "tab_colours", "type": "bool", "default": true}, + {"id": "tab_fade_ms", "type": "range", "default": 240} + ], + "tabs": { + "SKETCH": {"panel": "#123028", "viewport_top": "#103128", "viewport_bottom": "#204a3c"}, + "RENDER": {"panel": "#302039", "viewport_top": "#30203d", "viewport_bottom": "#493252"} + } +} +``` + +The [imported light/dark fixtures](../../../../crates/ui-egui/tests/fixtures/theme-tabs.json) +also provide Model surfaces. No built-in theme declares tab surfaces. With `tab_colours` +off or absent, every tab keeps the base palette. With it on, `tab_fade_ms` blends panel and +viewport surfaces together in Oklab with an ease-out cubic curve; zero switches instantly. +A tab change during a fade starts from the colours currently displayed. Opacity readability +floors also apply to tab panels and interpolated alpha. A viewport set to the opposite light/dark +palette keeps that palette; a flat background preference takes precedence over tab gradients. Definitions and authored defaults +roundtrip through theme export; saved option values are excluded. + +Render's untouched, unsaved preview also uses these animated surfaces, including viewport +palette and flat-colour preferences. Explicit scene backplates, transparency, environments, +gradient backdrops and saved setups keep their existing background instead. Saved setups do +not record whether their background was explicitly chosen, so even a setup saved only for its +camera conservatively retains its scene backplate. With tab surfaces absent/off, Render keeps +its original flat scene background. Theme surfaces never change rendered images or exports. + +Gruvbox has two cards, **Gruvbox Dark** and **Gruvbox Light**. Each offers a +**Contrast** choice: **Soft**, **Standard** (the default), or **Hard**. These are the +same six palettes as the former separate cards, including their icon colours. +Saved selections and settings profiles using the former `-soft` and `-hard` ids +select the corresponding family and contrast. Selecting a family again restores its +last contrast; Reset returns it to Standard. + +Up to 32 overlays apply in file order, after the base palette and the theme's own value tables. +Later matching entries win by key. Every overlay has one `when`: `{"opt":"id","eq":value}` +for an exact, correctly typed comparison, or `gte`, `lte`, or both for inclusive range bounds. +A choice comparison must name a declared choice. Unknown option ids, mixed comparisons and +malformed values are refused with the overlay's one-based position. An overlay may override +`colors`, `radii`, `borders` and `icon_palette`; all their existing key and value checks apply. +Overlays only select literal tables: there are no scripts, expressions or interpolation. + +The optional integer `requires` defaults to 1, this app's supported level. A larger level +loads the authored base value tables, disables options and overlays, and exposes a +`needs_newer` flag through the theme API for future UI. The theme-definition API can export +options and overlays intact; the existing token snapshot export keeps its previous shape. +The value resolver reports notices for unknown saved ids, wrong types, unknown choices, +non-finite numbers and clamped values. Invalid saved values for declared options fall back to defaults; +unknown saved ids are omitted because they have no definition or default. + #### Follow a theme file In the desktop app, **Follow a theme file** (under the themes) takes a theme file that another diff --git a/docs/book/src/reference/glossary.md b/docs/book/src/reference/glossary.md index 004e28c880..7cb85b2778 100644 --- a/docs/book/src/reference/glossary.md +++ b/docs/book/src/reference/glossary.md @@ -214,7 +214,8 @@ It is a fit point spline through fixed points that the formulas place, and they when the design's parameters change. Compare *spline*, whose points are placed by hand. - **In SolidCraft:** `sketch.equation_curve`, `sketch.equation_curve.edit`; the `equations` - design-file key of a sketch. + design-file key of a sketch; `variable_unit` says whether the variable is a plain number or a + length like its range. - **Don't use:** formula curve, function curve, parametric curve (for the command). - **Elsewhere:** equation-driven curve, curve from equation, function plot. - **For translators:** "equation" is a mathematical formula, not a balance or an equality constraint. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index b5d560ae05..036a97a70e 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -17,6 +17,13 @@ as `toast::close`: `toast:status:close`, `toast:space_mouse_offer:close` a `toast:stand_up:close` (#7971). Those that go by themselves draw a thin ring around it that shrinks over their time; the ring is full again while the pointer is on a toast that waits for it. +Live details publish `details:field:`, `details:show_all` and `details:action:copy` +handles. `details_findings` and `details_readout` are explicitly labelled component-only +`ui.status_fixture` presets. Real card settings execute the normal analysis/Section commands; +agents can read the same structured settings and retained Continuity records with the pure +`view.status_details` engine command. Its `current` flag guards geometry targets, and its +per-record limits prevent mixed measurements from being mistaken for one global tolerance. + The control channel is **on by default**. SolidCraft listens on `127.0.0.1` only, for JSON lines, so agents can drive the app without a launch flag. Every connection still needs this launch's token (below); nothing else on the machine, and nothing off it, can send a command. @@ -177,7 +184,7 @@ reply without `param_check` as unchecked. The bridged MCP server asks once per c | `engine.execute` | `command`: command id, `params`: JSON object | the command's result | | `engine.script` | `commands`: `[{command, params}, …]` | results, stops at the first error | | `engine.batch` | `commands`: `[{command, params}, …]` (at most 10 000), `rollback?`: bool (default true), `timings?`, `profile?`, `coalesce_parameters?`: bool (default false) | `{completed, results, undone}`, plus `failed: {index, command, error}` when a step fails, and with `timings` `timings: [{index, command, ms}]` (the failing step included) and `total_ms`; `profile` adds each step's `profile: {boolean_ms, tessellate_ms, other_ms}`. Runs the steps in one request, so within one frame, each as `engine.execute` runs it. The first failure stops the batch, and with `rollback` the design goes back to how it was before it (`undone`: the steps taken back, `rolled_back`: whether the design is back as it was, `false` only when the checkpoint could not restore it). Refused before anything runs, with an error starting `engine.batch unavailable`, while the agent cursor is shown, when a step loads a file (`doc.open`, `file.insert_step`, `file.insert_mesh`) or when it goes to another design's tab (`component.edit_in_place`, `component.finish_edit`, which `engine.script` refuses too). Send such steps one by one. A step the batch would certainly refuse at its turn is found before the first step runs (#5853): a step without a command, an unknown command id (with the closest ids) or, with strict parameter checking, a call that breaks its command's contract. The reply is then that step's failure with `completed: 0`, `undone: 0`, `not_run` listing every step and a `note`; nothing has run. With `coalesce_parameters`, each run of consecutive plain parameter edits (`parameters.change` with only a name and an expression, or with `changes`) is set as one `parameters.change {changes}`, so the design rebuilds once for the run. Every step still gets its own result (with `coalesced`: the run's length), and the run is one undo step. The run skips the states between its edits: an edit that would be refused on its own, but not once the run's later edits are in, goes through. A run whose combined edit is refused (a new parameter, a bad expression) runs step by step, so the refused step is named. Answered when done, for up to 3600 s | -| `engine.commands` | — | every command: id, label, tab, panel, icon, shortcut, params doc, enabled | +| `engine.commands` | — | every command: id, label, tab, panel, icon, shortcut (the user's current first key, or null when cleared), params doc, enabled | | `document.revision` | — | `{document_id, revision}`: the active design's id and a number that moves whenever the design changes, for programs that follow it ([Following the design](#following-the-design)) | | `control.subscribe` | — | `{document_id, revision}`, as `document.revision`; the connection is then sent a `design.changed` line whenever they change ([Following the design](#following-the-design)) | | `document.inspect` | `measure?`: bool | parameters, timeline (errors, timings), bodies, sketches, selection (assembly joints: `engine.execute` `joint.inspect {joint?}`, which reads only: each joint's two origins with the occurrence, the snap as saved, the feature, face, edge or sketch curve its names refer to, and whether it resolves or why not; kind, offset, angle, flip, values, limits, expressions and motion links) | @@ -348,6 +355,9 @@ to hide them while keeping the total distance and the dialog's ΔX/ΔY/ΔZ value overlays remain visible until Measure closes. Overlay handles `measure:label:x`, `measure:label:y` and `measure:label:z` identify the extra labels; `prefs:measure_axis_distances` identifies the checkbox. +A `ui.drag` from `measure:label` or one of the axis labels moves that label (screen offset, kept +while the measured items stay the same; a double-click puts it back). It picks nothing and starts +no box selection. ## UI @@ -360,7 +370,7 @@ labels; `prefs:measure_axis_distances` identifies the checkbox. | `ui.start` | `command`, `params?` | like a toolbar click: starts the sketch tool or dialog for the command. `params` sets what the dialog opens on, and nothing runs until OK: `joint.drive` takes `joint` (id or name; without it, the joint picked in the browser, else the first joint that moves; a rigid joint opens to say why it has nothing to drive, with an Edit Joint button, handle `drive:edit_joint`) and `values` (numbers in degrees or millimetres, or expressions); `motion.study` takes `study` (id or name), which opens that study to edit and chart. A command that takes no params is refused before it starts; a param the dialog doesn't take is refused and the dialog closes | | `ui.click` | `x`, `y` (screen points), `button?`: left/right/middle, `shift?`, `ctrl?`, `double?` | real pointer input | | `ui.move` | `x`, `y`, `shift?`, `alt?`, `ctrl?`, `cmd?` | pointer movement without a button; modifiers apply until the next synthetic move/click; a move with omitted modifiers releases them. With `nav: "touchpad"`, Shift pans and Alt/Option orbits. | -| `ui.drag` | `x0`, `y0`, `x1`, `y1`, `button?`, `shift?`, `ctrl?`, `steps?`, `hold?`, `hold_end?` | press, move, release; a left drag on the model is a box selection (left to right: window, right to left: crossing); with OpenSCAD navigation, plain left-drag on empty space orbits, Shift+left-drag box-selects, and drags starting on sketch geometry edit it; `hold` keeps the button down that many frames first (a right press-and-hold opens the marking menu, releasing over a direction picks it), `hold_end` that many frames at the end (a sketch drag shows its snap) | +| `ui.drag` | `x0`, `y0`, `x1`, `y1`, `via?`, `button?`, `shift?`, `ctrl?`, `steps?`, `hold?`, `hold_end?` | press, move, release; `via` is a list of `[x, y]` points passed through on the way (`steps` moves to each); a left drag on the model is a box selection (left to right: window, right to left: crossing), or with `lassoSelect` on (`ui.set`, the SELECT panel's Lasso) a freehand lasso that takes what is wholly inside it; with OpenSCAD navigation, plain left-drag on empty space orbits, Shift+left-drag box-selects, and drags starting on sketch geometry edit it; `hold` keeps the button down that many frames first (a right press-and-hold opens the marking menu, releasing over a direction picks it), `hold_end` that many frames at the end (a sketch drag shows its snap) | | `ui.menu` | `x?`, `y?`, `target?`, `close?` | with a point: open a context menu there as a right-click would (the viewport's marking menu, or a browser menu with `target: {"type": "body", "name"} \| {"type": "sketch", "id"} \| {"type": "component", "id"}`); without: the open menu (`radial` and `items`: id, label, shortcut, enabled, icon, placeholder; blank icons on headings/separators have `placeholder: false`) | | `ui.icon_audit` | `reset?`, `stop?` | `reset: true` enables collection on the UI thread and clears previous requests; returns `{requested: [{name, master, placeholder}]}` sorted by requested id. Each read drains names collected since the previous read/reset. Collection stays enabled until `stop: true` (which returns pending names before disabling it). Includes aliases, variable/table ids and cached paints. | | `ui.menuPick` | `item`: id or label | runs an item of the open menu, as clicking it does | @@ -385,9 +395,9 @@ labels; `prefs:measure_axis_distances` identifies the checkbox. | `ui.documents` | `action?`: `new`, `switch`, `close`, `quit`; `index?`; `force?` (close without asking); `answer?`: `save`, `dont_save`, `cancel` (answers "save changes?"); `open_elsewhere?`: `read_only`, `anyway`, `cancel` (answers "open elsewhere" for the first Open waiting); `changed_on_disk?`: `save_copy`, `overwrite`, `cancel` (answers "changed on disk" before a Save) | the open designs (tabs): names, unsaved flags, the active one, a pending "save changes?" prompt, whether a quit is asking, whether the active design is `read_only`, the Opens waiting for "open elsewhere" (`open_elsewhere`) and a waiting "changed on disk" (`changed_on_disk`) | | `ui.file` | `path`; `insert?`: bool | starts an interactive background Open or Insert; poll `ui.inspect.file_busy` for completion; the window stays responsive | | `ui.home` | `open?`: bool; `sample?`: index | the start page: show or hide it, open a built-in sample (once built); returns `open` and the recent designs | -| `ui.shortcut` | `command`, `key?`, `slot?`, `replace?` | a command's keys (`key`: the first, shown in menus; `keys`: all), or rebinds it: `key` becomes its only key, or with `slot` replaces its key there (one past the last adds a key, `""` there removes that key); `""` without `slot` clears them; a key another command uses is refused unless `replace` | +| `ui.shortcut` | `command`, `key?`, `slot?`, `replace?` | a command's keys (`key`: the first, shown in menus; `keys`: all), or rebinds it: `key` becomes its only key, or with `slot` replaces its key there (one past the last adds a key, `""` there removes that key); `""` without `slot` clears them; key names are case-insensitive and modifiers are normalized (`shift+ctrl+t` becomes `Ctrl+Shift+T`); unknown and reserved keys are refused; a key another command uses is refused unless `replace` | | `ui.help` | `item?`: `about`, `updates` (checks for updates and shows the result), `shortcuts` (read only), `report` (opens a report draft for review), `crashes` (the Crash Reports window) | the version, commit, build date and diagnostic text | -| `ui.prefs` | any of the [`ui.prefs` settings](#uiprefs-settings) below; also `open`, `button` and `stand_up` (listed there) | sets preferences (kept between runs), returns them all plus `dialog` (the open page or null), `apply_enabled` (whether the dialog's Apply has a change to keep), `dark`, `viewport_dark` (whether the 3D view is dark), `stand_up_offer` (the offered bodies, or null), `stand_up_note` (whether that offer is the note), `theme_followed` and `theme_follow_error` | +| `ui.prefs` | any of the [`ui.prefs` settings](#uiprefs-settings) below; also `open`, `button` and `stand_up` (listed there) | sets preferences (kept between runs), returns them all plus `dialog` (the open page or null), `apply_enabled` (whether the dialog's Apply has a change to keep), `dark`, `viewport_dark` (whether the 3D view is dark), `stand_up_offer` (the offered bodies, or null), `stand_up_note` (whether that offer is the note), `theme_followed`, `theme_follow_error`, `theme_option_defs`, `theme_option_values` and `theme_option_notices` | | `ui.capture` | `action?`: `capture`, `revert`, `cancel` | answers the Capture Position question (asked when a command starts or the design is saved while components were moved without capturing); without `action`, what waits | | `ui.window` | `action`: minimize, maximize, restore, toggle, close | what the title bar's caption buttons do (on Windows and Linux the application bar is the title bar); close asks about unsaved designs first | | `ui.agent_cursor` | `show` (bool), `speed`: a number from 1 to 5 (times the normal speed) or `"instant"` (no animation: the command runs at once and the pointer jumps to where it acted), `follow_camera` (bool); all optional | the settings. While the cursor is shown, `engine.execute` first glides to the command's toolbar button and to each pick, then runs and replies; later requests wait their turn. The pointer shows the command's localized name, including commands inside a closed toolbar group; an animated batch names each command it visits. It also follows the pointer input sent with `ui.move`, `ui.click`, `ui.drag` and `ui.scroll`, as each event reaches the app, and a press leaves a ripple. `"animate": false` on a call skips it | @@ -395,7 +405,7 @@ labels; `prefs:measure_axis_distances` identifies the checkbox. | `engine.execute` → `browser.expand_all` | `path?`: occurrence ids; `component?`: id; omit both for the whole tree | Open every Browser row under that scope, including folders that start closed (Origin, Named Views, Features). Display only: not saved in the design, not an undo step. | | `engine.execute` → `browser.collapse_all` | same | Close those rows. The design row stays open when the whole tree is collapsed. | | `engine.execute` → `view.history_layout` | `width?`, `dock?`: `bottom`, `left`, `under_browser`, `collapsed?` | Resize/dock the history without opening a dialog or changing the design. Contents respond to measured width; browser-stacked history shares its width and splits the height at the remembered share (`browser_share`, half until the seam between the two cards is dragged). One folded header gives the expanded card the rest; two folded headers stay at the top. | -| `ui.agent_chat` | `features?`, `toolbar_button?`, `connect_at_start?` and `open_at_start?` (connect the agent started last, and open the panel, when SolidCraft starts; both off by default, never with `--control`), `permissions?` (`ask`, `edits`, `all`), `add_agent?` ({`name`, `kind`: `acp`, `api` or `anthropic` (`api` is OpenAI-compatible, `anthropic` Anthropic-compatible), `command` or `base_url` + `model` + `request_timeout_s?` (seconds one model request may take, 300 by default, rounded and clamped to 30 to 3600)}; no keys), `add_dialog?` (`installed`, `custom`, `api`, `anthropic`, or `false` to close), `default_agent?`, `agent?` (the conversation's agent, by id), `mode?` (the agent's own mode), `provider?` (which provider's models the model picker lists, when they come from several), `model?` (one of its models), `open?`, `side?` (`left`, `right`), `minimized?`, `popped?` (in its own window), `width?`, `picker?`, `refresh?`, `connect?`, `stop?`, `attach?`: a file's path (a picture, a text file or a CAD file, for the next message; CAD files are imported at once), `remove_file?` (index of a file on the next message), `send?`: text (files alone make a message too), `attach_view?`, `attach_selection?`, `pick?` (true: open the context picker), `permission?`: an option id or `"cancel"` (the newest question of the agent's own), `approve?`: true, false or `"always"` (the oldest request held for Allow/Deny), `cancel?` (Stop: the queue stays), `send_now?` (stop the running step, then send the queued messages), `remove_pending?` (index), `new_chat?` (clear the chat, new session), `undo?` (the last message's changes) | the chat's state: `features`, `agents` (found and added), `currentAgent`, `state` (off, starting, ready, busy, auth, exited, failed: the agent never got ready, and Connect reads Retry), `connectAtStart`, `openAtStart`, `lastAgent` (the agent started last), `popped`, `window` (its place and size), `agent`, `modes`, `mode`, `models`, `model`, `modelWarning` (the model capability warning text, or `null` when unknown or tool support is reported; checked asynchronously and never blocks sending), `turnWarning` (the latest completed turn's no-tool notice, otherwise `null`; cleared on the next turn or new chat), `providers` (`name` and `count` of each, empty with one provider), `provider` (the one listed), `authMethods`, `toolsPort`, `pid`, `waiting`, `turn`, `pending` (queued messages, with their `files`), `attachments` (the files on the next message, by name), `loading` (pictures still being read for it, by name), `stopped`, `detection` and the conversation (`items`). With `features` off only the settings apply. Desktop only; see [Agent chat](agent-chat.md) | +| `ui.agent_chat` | `features?`, `toolbar_button?`, `connect_at_start?` and `open_at_start?` (connect the agent started last, and open the panel, when SolidCraft starts; both off by default, never with `--control`), `permissions?` (`ask`, `edits`, `all`), `add_agent?` ({`name`, `kind`: `acp`, `api` or `anthropic` (`api` is OpenAI-compatible, `anthropic` Anthropic-compatible), `command` or `base_url` + `model` + `request_timeout_s?` (seconds one model request may take, 300 by default, rounded and clamped to 30 to 3600)}; no keys), `add_dialog?` (`installed`, `custom`, `api`, `anthropic`, or `false` to close), `edit_dialog?` (the id of an agent you added: the same dialog filled with what it holds, as its **Edit** button opens it; the key is never shown), `default_agent?`, `agent?` (the conversation's agent, by id), `mode?` (the agent's own mode), `provider?` (which provider's models the model picker lists, when they come from several), `model?` (one of its models), `open?`, `side?` (`left`, `right`), `minimized?`, `popped?` (in its own window), `width?`, `picker?`, `refresh?`, `connect?`, `stop?`, `attach?`: a file's path (a picture, a text file or a CAD file, for the next message; CAD files are imported at once), `remove_file?` (index of a file on the next message), `send?`: text (files alone make a message too), `attach_view?`, `attach_selection?`, `pick?` (true: open the context picker), `permission?`: an option id or `"cancel"` (the newest question of the agent's own), `approve?`: true, false or `"always"` (the oldest request held for Allow/Deny), `cancel?` (Stop: the queue stays), `send_now?` (stop the running step, then send the queued messages), `remove_pending?` (index), `new_chat?` (clear the chat, new session), `undo?` (the last message's changes) | the chat's state: `features`, `agents` (found and added), `currentAgent`, `state` (off, starting, ready, busy, auth, exited, failed: the agent never got ready, and Connect reads Retry), `connectAtStart`, `openAtStart`, `lastAgent` (the agent started last), `popped`, `window` (its place and size), `agent`, `modes`, `mode`, `models`, `model`, `modelWarning` (the model capability warning text, or `null` when unknown or tool support is reported; checked asynchronously and never blocks sending), `turnWarning` (the latest completed turn's no-tool notice, otherwise `null`; cleared on the next turn or new chat), `providers` (`name` and `count` of each, empty with one provider), `provider` (the one listed), `authMethods`, `toolsPort`, `pid`, `waiting`, `turn`, `pending` (queued messages, with their `files`), `attachments` (the files on the next message, by name), `loading` (pictures still being read for it, by name), `stopped`, `detection` and the conversation (`items`). With `features` off only the settings apply. Desktop only; see [Agent chat](agent-chat.md) | | `ui.resize` | `width`, `height` | | | `app.quit` | `force?` (quit without asking about unsaved designs) | `quitting` (the window closes), `asking` (a "save changes?" prompt is open) | @@ -423,7 +433,7 @@ One setting per line, sorted by name. A change that adds a setting adds its own - `check_updates` (default true; [updates](book/src/guide/updates.md)) - `color_vision` (`off` (default), `protan`, `deutan`, `tritan`: colour vision modes, #778) - `default_units` -- `dialog_buttons` (`bottom` (default), `top`: a feature dialog's OK, Cancel and Apply sit in its title row instead of under its inputs, #6708; the buttons keep their handles, and `dialog:close` is then Cancel's) +- `dialog_buttons` (`bottom` (default), `top`: a feature dialog's OK, Cancel and Apply sit in its title row instead of under its inputs, #6708; the buttons keep their handles, and `dialog:close` is then Cancel's; the Sketch panel's Finish Sketch row moves above its options, #9860) - `dialog_place` (`edge` (default) or `left_column`: command dialogs open in the left column in place of the browser and the History, #6707) - `extrude_auto_to_object` (default false: in the Extrude dialog, with a profile picked, a face, vertex or parallel plane picked off its plane switches the extent to To Object; it applies to Extrude dialogs opened afterwards) - `frame_rate_details` (default false: with the frame rate, a frame-time graph of the frames drawn with the triangles over it, a frame-time histogram and what the GPU drew; clicking the readout switches it) @@ -432,11 +442,14 @@ One setting per line, sorted by name. A change that adds a setting adds its own - `history_sketches` (`in_order` (default), `under_feature`: the History at the left lists a sketch under the first feature built from it, #6489; the timeline at the bottom keeps the creation order) - `hole_moves` (default false: a placed Single hole has a drag handle (`hole:`), and a pick on a face that has a hole moves it there) - `hole_snaps` (default false: a Single hole snaps to its face's middle and round edges' centres) +- `home_fit_size` (0.1…1, default 1: how much of the view the model fills when Home, Fit, an opened design or a navigator view change frames it, with `navigator_fit` on or off; 1 fills the room between the panels, the compass and the navigation bar, as before; #10091) - `joint_rotation_coarse` (default 10°): coarse Assembly Move rotation step, in 0.001…180°. - `joint_rotation_fine` (default 0.1°): fine Assembly Move rotation step, in 0.001…180°. +- `keep_work_on_tab_switch` (default false: each design tab keeps its open dialog or tool, with what was picked and typed in it, and its toolbar tab while another design is shown, and has them back when it is shown again; off, a switch cancels them and shows MODEL, #9360) - `language` (`en`, `ar`, `ja`, `de`, `fr`, `he`, `ko`, `ms`, `ru`, `uk`, `zh`; see [localization](localization.md)) - `length_decimals` - `line_drag_draws_one` (default false: in the Line tool, a press, a drag and the release draw one line from the press to the release and end there, snapping as clicks do; clicks still chain, and a drag under 24 points is a click; off, a drag places a point where it is let go, #7247) +- `line_hover_tangent_arc` (default false: in the Line tool, after a segment, moving away from the chain's last point and resting back on it for about a third of a second makes the next click a tangent arc, and the same again goes back to lines; off, resting there does nothing, #9489) - `logo_accent` (bool, default false: the application bar's logo in shades of the theme's accent instead of the theme's logo colours) - `look_at_anim` - `look_at_speed` @@ -446,6 +459,7 @@ One setting per line, sorted by name. A change that adds a setting adds its own - `msaa` - `nav` (`fusion`, `solidworks`, `inventor`, `alias`, `blender`, `tinkercad`, `openscad`, `touchpad`) - `nav_bridge_enabled` (default false) and `nav_bridge_port` (default 8765: a device's own app on this computer moves the view, [the navigation bridge](3d-mouse.md#device-apps-the-navigation-bridge)) +- `navigator_fit` (default false: a view chosen on the view cube or the axis gizmo also looks at the middle of the model from the one distance that fits its bounding sphere in the free part of the view, at `home_fit_size`; Home and an opened design use the same zoom and centre while it is on, so views chosen after them only turn; #10091) - `offer_stand_up` - `orbit_around` (`view_centre` (default: what Pan moves and Fit frames), `origin` (the world origin stays still on screen), `cursor` (the model point under the pointer when the drag began); the older `orbit_cursor` boolean still sets `cursor` when true) - `orbit_reverse` (default false: Shift with two-finger scrolling orbits the other way) @@ -477,10 +491,11 @@ One setting per line, sorted by name. A change that adds a setting adds its own - `status_bar` (default false: a thin bar along the window's bottom edge, below History, handle `status:bar`: what is being edited (`status:editing`), the open sketch's state in its colour (`status:sketch`) and the design's units (`status:units`, a click opens the Units menu, whose rows run `document.units`); the sketch's state then leaves the view's top-left corner, #7243) - `step_values` (default false: Up/Down and the wheel step a plain number in a focused value field) - `theme` (`dark`, `light`, `system` or a theme file's id: a built-in one such as `gruvbox-dark`, or an imported one; the reply's `theme_ids` lists them) -- `theme_export: true` (the reply's `theme_file` is the theme on now as a complete file; see [colour themes](book/src/guide/ui-tour.md#colour-themes)) +- `theme_export: true` (the reply's `theme_file` is the active theme, including a followed file; themes with options retain their authored definitions, defaults and overlays, while older themes keep the complete snapshot shape; saved option values are excluded; see [colour themes](book/src/guide/ui-tour.md#colour-themes)) - `theme_follow` (desktop only, default `""`: a `solidcraft-theme/1` file's path to follow as the theme, reloaded within about a second whenever it changes and when the window gains the focus; the file is read at once, and a file that doesn't read keeps the last good theme; `""` stops following, as does setting `theme`; in the browser it is an error; the launch flag `--theme-file PATH` follows a file for one run without saving it; the reply's `theme_followed` is the path followed now or null, and `theme_follow_error` why its last read was not applied, or null) - `theme_import` (a `solidcraft-theme/1` file's JSON or text: adds the theme and turns it on; the reply's `theme_imported` is its id) -- `theme_remove` (an imported theme's id) +- `theme_options` (default `{}`): a map of theme id to option-id/value map, merged per theme; `{"theme_options":{"my-theme":{"enabled":true,"amount":5}}}` changes those options, `null` resets one option, and an empty map resets that theme. Unknown themes or option ids, wrong types, unknown choices and out-of-range numbers refuse the entire call and name the key, before imports, removals, dialogs or other preference effects occur. +- `theme_remove` (an imported theme's id; also removes its saved option values) - `timeline_origin_bodies` (default false: the History on the left or under the browser starts with the active component's Bodies folder and the Origin, the browser's rows with their eyes, folds and menus; the bottom History is unchanged) - `toast_place` (`corner` (default: notices and offers stack in one column in the view's bottom-right corner, each 360 points wide, buttons right-aligned with the main action right-most, #7893), `centre` (at the bottom centre above the view toolbar, each as wide as its text, as before)) - `trackpad_pan` (default true: two-finger scroll pans, except Touchpad always zooms; pinch zooms) @@ -496,9 +511,36 @@ One setting per line, sorted by name. A change that adds a setting adds its own - `zoom_stop_at_model` (default false: the 3D mouse and navigation bridge stop zoom at the surface in the middle of the view; drag-zoom holds its press point; independent of `zoom_to_cursor`) - `zoom_to_cursor` +The built-in `gruvbox-dark` and `gruvbox-light` themes each expose the `contrast` +choice (`soft`, `standard` default, `hard`). For example: +`{"theme":"gruvbox-dark","theme_options":{"gruvbox-dark":{"contrast":"soft"}}}`. +The former `gruvbox-dark-soft`, `gruvbox-dark-hard`, `gruvbox-light-soft` and +`gruvbox-light-hard` ids remain accepted for compatibility and select the matching +family and contrast; replies and `theme_ids` expose only the two canonical families. +An explicit canonical option change or reset in the same call wins over the legacy +selection. Legacy option-map keys are also accepted; canonical keys take precedence +when both occur in a call. Imported themes cannot claim the retired built-in ids. + +Theme option values persist per theme, including a followed file's own id, through the same +preference store in the browser and on desktop. Switching themes restores their saved values; +Interface's Restore Defaults clears them. Preference exports and settings profiles carry values; +`theme_export` carries the author's defaults. Saved entries that no longer fit a definition remain +stored and use the default with a notice; unknown ids have a notice and no effective value. + +For the active theme, `theme_option_defs` lists the declared options in `ordered_options` order: +`id`, `type`, `default`, type-specific `min`, `max`, `step`, `unit` or choice `values` (each with +`id` and `label`), `parent`, `advanced`, `section`, `label`, `help`, `standard` and `available`. +Labels, help and choice labels use the current language with English fallback. Unavailable +definitions include `reason`; standard options currently have `available: false` and +`reason: "not implemented"`. `theme_option_values` contains the effective values, including +defaults and values of inactive children. `theme_option_notices` contains `{id, kind}` entries, +where `kind` is `unknown_option`, `wrong_type`, `unknown_choice`, `non_finite` or `out_of_range`. +Without an active theme file these fields are `[]`, `{}` and `[]`. Portable input +and output schema types live in `solidcraft_engine::schema::ui_prefs` (`PrefsParams`, `PrefsReply`). + Other `ui.prefs` parameters: -- `open` (a page: General, Interface, Design, Navigation, Keyboard Shortcuts, Sketch, Graphics, AI, Plugins; Shortcuts, Assistant and Sketch & selection are aliases, and the former Selection page opens Sketch) opens the Preferences dialog there +- `open` opens Preferences at General; Look: Appearance, Layout, Graphics; Work: Design, Sketch, Tools; Input: Navigation, 3D mouse, Shortcuts; or Extend: Assistant, Plugins. Names are case-insensitive. Legacy aliases remain accepted: Interface → Appearance, Interface/navigation bar → Layout, Keyboard Shortcuts → Shortcuts, AI → Assistant, and Selection, Sketch & selection or Sketch and selection → Sketch. Replies use the canonical page name. The corresponding legacy `prefs:page:` scenario handles also remain aliases of the canonical page rows. - `button`: `ok`, `cancel`, `apply`, `defaults` - `stand_up: true` stands up a just-opened Y-up file, `stand_up: false` leaves it as it is ("Leave it": the offer, dialog or note, closes and `offer_stand_up` stays on) - the dialog's buttons are the handles `stand_up:stand`, `stand_up:leave` and `stand_up:never` @@ -515,7 +557,10 @@ The guide spans the reference and cursor projection, including positions inside The two most recent references are kept, and both guides can be drawn. A single guide does not move the cursor. Set the preference to `false` to hide the guides. Snap suppression (Alt, unless rebound) does the same for that gesture. Snapping the cursor to where those -guides cross is separate, and off unless `sketch_recent_snap` is true. +guides cross is separate, and off unless `sketch_recent_snap` is true. While a drawing tool +waits for a point, `sketch_tracking_points` (default true) makes every shown sketch point +other than the origin a reference too (line and arc ends, rectangle corners, free points); +`false` keeps centres and midpoints only. ### Continuous camera motion @@ -573,8 +618,12 @@ animated view changes is enabled (the default), at the configured animation spee The cube runs `view.navigate` through the command engine. Programmatic callers pass `camera` and either `direction: [x,y,z]` in the camera's up-axis frame or `action` (`left`, `right`, `up`, -`down`, `clockwise`, `counterclockwise`, `home`, `fit`). The result contains `camera` and `fit`; -UI callers apply the camera with their animation preference, without editing the design. +`down`, `clockwise`, `counterclockwise`, `home`, `fit`). `fit: true` with a `direction` or a +turn asks for the whole model to be framed from the new direction as well, as `home` does. The +result contains `camera` and `fit`; UI callers apply the camera with their animation +preference, without editing the design. The Navigation preference `navigator_fit` (off by +default) makes every view chosen on the cube or on the axis gizmo frame the model this way: +looking at its middle from the one distance that fits its bounding sphere, which Home then shares. Shift-orbit snapping includes 45-degree edge views and isometric corner views, with a lightly damped spring transition and a small boundary margin to prevent flicker. diff --git a/docs/design/in-app-agent-chat.md b/docs/design/in-app-agent-chat.md index 203c07e11b..bc6cd06c37 100644 --- a/docs/design/in-app-agent-chat.md +++ b/docs/design/in-app-agent-chat.md @@ -26,7 +26,7 @@ What the prototype uses: | Direction | Message | Use | |---|---|---| | → agent | `initialize {protocolVersion: 1, clientCapabilities, clientInfo}` | We declare no file system and no terminal (`fs.readTextFile/writeTextFile: false`, `terminal: false`), and terminal login (`auth.terminal: true`, plus the older `_meta["terminal-auth"]`). The reply carries `agentInfo` and `authMethods`. | -| → agent | `session/new {cwd, mcpServers}` | `mcpServers` holds one stdio server: `solidcraft-cli mcp --connect 127.0.0.1:PORT`. `cwd` is the open design's folder (else SolidCraft's own `agent` folder). | +| → agent | `session/new {cwd, mcpServers}` | `mcpServers` holds one stdio server: `solidcraft-cli mcp --connect 127.0.0.1:PORT`. `cwd` is the open design's folder (else SolidCraft's own `agent` folder). The system note goes in the standard `systemPrompt` field when the agent offers `sessionCapabilities.systemPrompt`, in `_meta.antigravity.systemPrompt.append` when Antigravity offers that, and always in `_meta.systemPrompt.append` for the Claude adapter. An agent with none of these doesn't get it. | | → agent | `session/set_config_option {sessionId, configId, value}` | Set an advertised select option, including a model, reasoning effort or mode. The complete returned `configOptions` snapshot confirms the state; rejection leaves the previous confirmed snapshot intact. | | → agent | `session/prompt {sessionId, prompt: [content]}` | One text block. The reply comes at the end of the turn with `stopReason` (`end_turn`, `cancelled`, `refusal`, …). | | → agent | `session/cancel {sessionId}` (notification) | Cancel button. Open permission prompts are answered `cancelled` first, as the spec asks. | @@ -184,12 +184,22 @@ conversation shows the merged step with an Undo button while it's still the late Each open design tab owns an `AgentChat`, including its transcript, backend session, agent selection, sign-in retry, draft, attachments, pending messages and approval state. `DocSlot` parks that state alongside its `Session`; switching restores both together. Panel visibility, -placement, agent discovery, endpoint model cache, settings dialogs and the desktop endpoint -starter remain window state. A new tab gets a fresh conversation. Closing a tab drops its +placement, agent discovery, the latest sign-in evidence per agent, endpoint model cache, +settings dialogs and the desktop endpoint starter remain window state. A new tab gets a fresh conversation. Closing a tab drops its backend; closing the last tab or replacing its design (including programmatic New, Open and -Recover commands or scripts) clears its conversation. +Recover commands or scripts from external callers) clears its conversation. Conversation state is in memory only and does not change the design file format. +An admitted request from the current chat carries its turn's validity flag through direct +dispatch, cursor animation and asynchronous file loading. Only while that request applies, +`with_tool_request` marks its document changes as agent-initiated. New/Recover replacements +retain the chat and reset its undo boundary to the new design. Opening a new tab groups the +old design's completed changes, moves the conversation and its endpoint into the new tab, +and starts a new undo segment without resetting the turn's limits. A failed batch that +restores its original design also restores its original turn boundary. Opening an already +active file is unchanged; opening a file owned by another tab is refused, preserving both +conversations. The marker is never taken from JSON and does not persist between requests. + Leaving a tab cancels its running turn, declines outstanding approvals, and merges completed changes into that design's undo history before swapping sessions. Its backend conversation remains available when the tab is selected again. Pending messages stay with that tab and wait diff --git a/docs/features.md b/docs/features.md index de2e6a4e47..f21489de8c 100644 --- a/docs/features.md +++ b/docs/features.md @@ -426,6 +426,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm | Command | Id | Toolbar | |---|---|---| | Bill of Materials | `assembly.bom` | SOLID › INSPECT | +| Mass Properties | `assembly.mass_properties` | – | ## `body.*` @@ -755,6 +756,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm | New Named View | `view.save` | – | | Shots Strip | `view.shots_strip` | – | | Continuous orbit | `view.spin` | – | +| Viewport Status Details | `view.status_details` | – | | Viewport Status Items | `view.status_items` | – | | Navigation tool | `view.tool` | – | | Top View (Ctrl+5) | `view.top` | – | diff --git a/docs/localization.md b/docs/localization.md index ee352f98a7..2e71098dfd 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -5,6 +5,13 @@ Japanese, Korean, Malay, Norwegian Bokmål, Dutch, Polish, Brazilian Portuguese, embedded catalog and one registration there; the preferences selector and validation use the same registry. +The `language` preference is a registered locale ID, or `system` (the default on a first start): +the app then matches the system's preferred locales — the OS locale on desktop, +`navigator.languages` in the browser, read with the `sys-locale` crate — against the registry: +exact tag, then base language, then English (`match_system_tags` in +`crates/ui-egui/src/i18n.rs`). A saved specific language always wins, so existing users keep +their choice. + ## Messages `assets/locales/en.json` defines stable message IDs and English fallback text. diff --git a/docs/mcp.md b/docs/mcp.md index 3bd30f84bb..8260913a3f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -153,7 +153,8 @@ file dialogs are never limited. Each tool says what it may do, for clients that ask before running tools (or auto-approve on these hints). `list_commands`, `describe_command`, `inspect_design`, `measure`, `body_topology`, `skill_reference`, `crash_reports`, `pending` and `running` are read-only. `set_parameter`, `undo` and `redo` -change the design undoably. `new_design` replaces it (destructive). `execute`, `batch`, `open`, `save`, `export` and +change the design undoably, and so does `check_parameters` (it changes the design and puts it back: +not read-only, not destructive). `new_design` replaces it (destructive). `execute`, `batch`, `open`, `save`, `export` and `screenshot` (whose `path` writes a file) can read, write, overwrite or delete files: they are not read-only, `destructiveHint` and `openWorldHint` are true. @@ -278,7 +279,7 @@ completely (from the notification area) and start it again to load the servers. They cover the workflow, the command shapes, how to verify with `measure`, and the known traps. With them an agent builds a part in a few `batch` calls and reads far less output. To install them for Claude Code, copy the folders into `~/.claude/skills/`; [skills/README.md](https://github.com/bherbruck/solidcraft/blob/main/skills/README.md) has the details. -The `skill_reference` tool returns these documents from the copies built into SolidCraft, so an agent without file access can follow them: `{"skill": "solidcraft-modeling"}` returns its `reference.md`, the default file. Mechanisms also has `reference.md`, checking has `reference/porting.md`, and every skill has `SKILL.md` (editing uses modeling's reference). It never reads a path from disk, and an unknown skill or file is refused with the available choices. SolidCraft's own chat turns the agent's file tools off, so this is how its agent reads the skills there. +The `skill_reference` tool returns these documents from the copies built into SolidCraft, so an agent without file access can follow them: `{"skill": "solidcraft-modeling"}` returns its `reference.md`, the default file. Mechanisms also has `reference.md`, checking has `reference/porting.md`, and every skill has `SKILL.md` (editing uses modeling's reference). It never reads a path from disk, and an unknown skill or file is refused with the available choices. SolidCraft's own chat turns the agent's file tools off, so this is how its agent reads the skills there. The instructions the server sends at `initialize` point agents at `skill_reference` first, then say how to plan the numbers, build in a few `batch` calls and verify each stage with `measure`; the same pointer is in the system note SolidCraft's own chat gives its agent. ## Tools @@ -286,12 +287,33 @@ The `skill_reference` tool returns these documents from the copies built into So |---|---|---| | `list_commands` | `filter?` | The command catalog: id, label, tab/panel, shortcut, parameter docs, enabled now. `filter` matches id, label, tab and panel. | | `describe_command` | `id` | One command's parameter docs (`params`) and, for commands with a typed contract (every new command, and more of the older ones as they are converted), the JSON Schemas (2020-12) of its parameters (`input`) and of a successful result (`output`); `null` for the others. The same as `execute` `engine.describe {command}`. | -| `execute` | `command`, `params?`, `verbose?` (default true) | Run any command with JSON parameters (`list_commands` shows them). Failures leave the design unchanged. A feature command replies with the feature (`feature`, `name`), every body (`bodies`) and the bodies it created, changed and removed (`created_bodies`, `changed_bodies`, `removed_bodies`). `verbose: false` uses the same compact reply as `batch`. | -| `batch` | `commands: [{command, params}]`, `rollback?` (default true), `verbose?` (default true), `timings?`, `profile?`, `coalesce_parameters?` (default false) | Run steps in order, stop at the first error and undo the completed steps. The reply names the failing step (`failed.index`, `failed.command`, `failed.error`), every result before it, `rolled_back` (true only when the design really went back to where the batch started), `undone`, `applied` (the indices of the steps whose changes are in the design) and `not_run` (the steps never sent). A step may name a tool instead of a command, with that tool's arguments as `params`: `set_parameter` (runs `parameters.change`; `value` or `expression`), `list_commands` (`engine.commands`, filtered by `filter` as the tool filters it), `describe_command` (`engine.describe`), `execute` (`{command, params}`), `measure`, `inspect_design` (`document.inspect`: each sketch as its counts, not its full contents, and no joints), `undo`, `redo`, `export`, `save`, `open`, `new_design` and `crash_reports`. `screenshot`, `body_topology` (use `model.edges` and `model.faces`), `batch`, `pending`, `running` and `skill_reference` can't be steps, nor can `component.edit_in_place` and `component.finish_edit`, however a step names them, as they go to another design's tab (send them with the `execute` tool on their own): such a batch fails before anything is sent. So does a step the batch would certainly refuse at its turn (#5853): a step without a command, an unknown command id (the error names the closest ids) or, with `--param-check strict`, parameters that break a command's contract. A headless session checks this itself; an app checks it against its own commands when it gets the batch, and a bridged server at `--param-check strict` checks the contracts before it sends the batch. `verbose: false` keeps the reply small in large designs: feature steps leave out the full body list (`created_bodies`, `changed_bodies`, `removed_bodies` stay) and sketch steps the ids they repeat outside `created`. Bridged to an app that has `engine.batch`, the whole batch is one control request (one frame of the app). Older apps, and apps showing the agent cursor, get the steps one by one. `timings: true` adds `timings: [{index, command, ms}]` for every step run, the failing one included, and `total_ms`. `profile: true` also adds each step's `profile: {boolean_ms, tessellate_ms, other_ms}`, its time in kernel booleans, in tessellation and the rest. Steps are timed where they run: in the app when bridged. An app without `engine.batch` is timed step by step, without `profile`. `coalesce_parameters: true` sets each run of consecutive plain parameter edits (`parameters.change` or `set_parameter` with a name and a value, or `changes`) together, so the design rebuilds once per run instead of once per edit. Steps between runs see the design up to date. Each step keeps its own result (`coalesced`: the run's length), and the run is one undo step. The run skips the states between its edits, so an edit refused on its own can go through with the run's later ones. A refused run goes step by step, naming the refused step. | +| `execute` | `command`, `params?`, `verbose?` (default true), `expect?` | Run any command with JSON parameters (`list_commands` shows them). Failures leave the design unchanged. A feature command replies with the feature (`feature`, `name`), every body (`bodies`) and the bodies it created, changed and removed (`created_bodies`, `changed_bodies`, `removed_bodies`). `verbose: false` uses the same compact reply as `batch`. `expect` checks the design after the command, as it does for `batch` (see below). | +| `batch` | `commands: [{command, params}]`, `rollback?` (default true), `verbose?` (default true), `timings?`, `profile?`, `coalesce_parameters?` (default false), `expect?` | Run steps in order, stop at the first error and undo the completed steps. The reply names the failing step (`failed.index`, `failed.command`, `failed.error`), every result before it, `rolled_back` (true only when the design really went back to where the batch started), `undone`, `applied` (the indices of the steps whose changes are in the design) and `not_run` (the steps never sent). A step may name a tool instead of a command, with that tool's arguments as `params`: `set_parameter` (runs `parameters.change`; `value` or `expression`), `list_commands` (`engine.commands`, filtered by `filter` as the tool filters it), `describe_command` (`engine.describe`), `execute` (`{command, params}`), `measure` (`summary: true` summarised as the tool summarises it), `inspect_design` (`document.inspect`: each sketch as its counts, not its full contents, and no joints), `undo`, `redo`, `export`, `save`, `open`, `new_design` and `crash_reports`. `screenshot`, `body_topology` (use `model.edges` and `model.faces`), `batch`, `check_parameters`, `pending`, `running` and `skill_reference` can't be steps, nor can `component.edit_in_place` and `component.finish_edit`, however a step names them, as they go to another design's tab (send them with the `execute` tool on their own): such a batch fails before anything is sent. So does a step the batch would certainly refuse at its turn (#5853): a step without a command, an unknown command id (the error names the closest ids) or, with `--param-check strict`, parameters that break a command's contract. A headless session checks this itself; an app checks it against its own commands when it gets the batch, and a bridged server at `--param-check strict` checks the contracts before it sends the batch. `verbose: false` keeps the reply small in large designs: feature steps leave out the full body list (`created_bodies`, `changed_bodies`, `removed_bodies` stay) and sketch steps the ids they repeat outside `created`. Bridged to an app that has `engine.batch`, the whole batch is one control request (one frame of the app). Older apps, and apps showing the agent cursor, get the steps one by one. `timings: true` adds `timings: [{index, command, ms}]` for every step run, the failing one included, and `total_ms`. `profile: true` also adds each step's `profile: {boolean_ms, tessellate_ms, other_ms}`, its time in kernel booleans, in tessellation and the rest. Steps are timed where they run: in the app when bridged. An app without `engine.batch` is timed step by step, without `profile`. `coalesce_parameters: true` sets each run of consecutive plain parameter edits (`parameters.change` or `set_parameter` with a name and a value, or `changes`) together, so the design rebuilds once per run instead of once per edit. Steps between runs see the design up to date. Each step keeps its own result (`coalesced`: the run's length), and the run is one undo step. The run skips the states between its edits, so an edit refused on its own can go through with the run's later ones. A refused run goes step by step, naming the refused step. | + +**`expect`** on `batch` and `execute` turns the verify step into part of the build. An agent +computes the volume, body count or bounding box a stage should produce before building it; +instead of a `measure` call and a comparison by hand after every stage, it gives them to the +call: `"expect": {"volume_mm3": 59824.9, "tol": 0.002, "bodies": 1, "bbox": {"min": [0, 0, 0], +"max": [140, 90, 30]}}`. Every key is optional (at least one of `volume_mm3`, `bodies` and `bbox`); +`tol` is relative to the expected volume (default 0.002, so 0.2 %), and each bounding-box +coordinate must lie within 0.05 mm. Once the call has gone through, the server measures the +design the way `measure` does (the total volume of the closed bodies, the body count and the +box round every body) and the reply gains +`"expectation": {"ok": bool, "measured": {"volume_mm3", "bodies", "bbox"}, "expected": {...}, "mismatch": ["volume: measured 59680, expected 59824.9 (-0.24 %)", ...]}`. +On a mismatch the tool result is flagged `isError: true`, with the mismatch text as its first +content block and the reply as the second, so the model notices; the design is kept as built +(undoably, as ever), and the agent decides whether to undo, edit a feature (`timeline.edit`) or +carry on. A batch that fails is not measured, and an `expect` the tool cannot use (an unknown key, +a value of the wrong type, a negative `tol`, a `bbox` without three coordinates a side) is +refused before anything runs. `expect` is an argument of the tool, not a key of a step: a step +carrying one is treated as any step with a key the batch does not know. Volume and body count +say nothing about which kernel built the design, which is the point: the check holds whatever +builds it. | `inspect_design` | `measure?` (default true), `sketches?` (default true), `joints?` (default true) | Design properties (`metadata`: project, author… and other tools' keys; set them with `execute` `document.metadata.set`), parameters, timeline (type, suppressed, rolled back, error, warning, timing), bodies (volume, area, centre of mass, bbox, face/edge/vertex counts, visible; `removed_later: true` while a sketch is being edited on a rolled-back timeline and a later feature deletes the body, which is then not shown), sketches (curves, points, constraints, profiles, DOF, solve status, visible), joints when the design has any (as `joint.inspect` gives them: each origin's occurrence, component and path, the snap as saved, the feature, face, edge or sketch curve (`sketch: {id, name, curve}`) its persistent names refer to, `resolves` and the `error` when it is lost; kind, offset, angle, flip, values, limits, expressions, motion links; `execute` `joint.inspect {joint}` gives one joint), selection, undo depth. Hide helper sketches, bodies or construction planes with `execute` `browser.visibility` (`{items, visible}` or `{folder, visible}`), and joint glyphs with `{joints: [id or name], visible}` or `{folder: "joints", visible}` (a hidden joint still constrains and drives; `joint.list` reports each joint's `visible`). | -| `measure` | `body?` or `items?` | Area (mm²), bbox, topology counts and `closed` per body. Volume (mm³), volume centre of mass, density and mass are reported only for closed, consistently oriented bodies (null or omitted for open surfaces). Volume/mass totals skip open bodies; `total.open_bodies_skipped` gives their count. Area and topology totals include all bodies. With `items` (one or two selections as `execute` takes them: `face`, `edge`, `vertex`, `sketch_curve`, `profile`), the picked geometry instead: faces and edges need only `type`, `body` and `point`; `index` is optional. See below. | -| `body_topology` | `body` | Edges (index, midpoint, length, ends) and faces (index, name, area, centroid, normal, `point`, and for a cylindrical face its `cylinder`: `origin`, `axis`, `radius`). An edge midpoint is the `[x,y,z]` that picks that edge for fillet and chamfer. A face's `point` lies on it and picks it (its centroid may not: that of a face with a hole is in the hole); a face's `name` picks it for the commands that take faces (`face.delete`, `solid.shell`, `solid.draft`, `solid.offset_face`, `solid.split_face`, `solid.replace_face`, and `face` of `sheet.convert`). | +| `measure` | `body?` or `items?`, `summary?` (default false) | Area (mm²), bbox, topology counts and `closed` per body. Volume (mm³), volume centre of mass, density and mass are reported only for closed, consistently oriented bodies (null or omitted for open surfaces). Volume/mass totals skip open bodies; `total.open_bodies_skipped` gives their count. Area and topology totals include all bodies. With `items` (one or two selections as `execute` takes them: `face`, `edge`, `vertex`, `sketch_curve`, `profile`), the picked geometry instead: faces and edges need only `type`, `body` and `point`; `index` is optional. See below. `summary: true` keeps one short object per body (`name`, `volume_mm3`, `bbox: {min, max}`, `faces`, `closed`; a body that could not be measured keeps its `error`), `body_count` and `total.volume_mm3`, nothing else: what a stage check against computed numbers needs, at a fraction of the size. It applies to the bodies reply, not to `items`. | +| `body_topology` | `body`, `faces?` (`all`, `cylinders`; default `all`) | Edges (index, midpoint, length, ends) and faces (index, name, area, centroid, normal, `point`, and for a cylindrical face its `cylinder`: `origin`, `axis`, `radius`). An edge midpoint is the `[x,y,z]` that picks that edge for fillet and chamfer. A face's `point` lies on it and picks it (its centroid may not: that of a face with a hole is in the hole); a face's `name` picks it for the commands that take faces (`face.delete`, `solid.shell`, `solid.draft`, `solid.offset_face`, `solid.split_face`, `solid.replace_face`, and `face` of `sheet.convert`). `faces: "cylinders"` replies `cylinders` instead of `edges` and `faces`: one entry per cylinder, `{radius, origin, axis, extent, names}`, the halves the kernel splits a cylinder into merged (same radius, same axis line; `origin` and `axis` as the first half's `cylinder` gives them), `extent` the `[min, max]` distance along `axis` from `origin` that the body's points on the cylinder span (the faces' `point`s and the ends of the edges lying on it; left out when none were found), and `names` the merged faces' names, any of which picks the face. Enough to check where every hole and boss is, in a few lines instead of the whole topology. | | `set_parameter` | `name`, `value` or `expression` (number or expression), `unit?`, `comment?` | Create or change a user parameter and recompute. `expression` is another name for `value` (the one `parameters.change` uses): give one of them, or both only when they agree; neither, or two that differ, is an error. Reports `recomputed` (features rebuilt), `restored` (features whose earlier result for the same inputs was reused, e.g. a value set back) and features that now fail. An expression `new_name = expression` renames the parameter as it sets it; in any other tool's expression parameter it adds the user parameter `new_name` and the input uses it (`defined`, `definitions` in the reply). | +| `check_parameters` | `set: {name: value}` (a number or an expression each, as `set_parameter`'s `value`), `report?` (`measure`, `cylinders`, `errors`; default all three) | Try parameter values without keeping them: one `edit.checkpoint`, every value set in one `parameters.change {changes}` (one rebuild; an app without `changes` gets one change per name), the reports read, then `edit.restore_checkpoint`, so the design, its parameters and its undo history are exactly as before. The reply has `values` (each parameter's value with the tried expressions in), `recomputed`, `measure` (volume, bbox, bodies and faces per body, as the `measure` tool reports them), `errors` and `warnings` (the features that fail or warn: `{feature, name, error}`) with `parameter_errors` (the parameters that don't evaluate), `cylinders` (every cylindrical face of every body as `{body, radius, origin, axis, faces, name, area}`; the halves a kernel splits one cylinder into are one entry, `origin` is the axis point closest to the world origin and `axis` points so that its first non-zero component is positive) and `restored: true`. A name the design has no parameter of, a value that is not a number or a string, an expression that does not evaluate or an unknown report item is an error, and nothing changes. If the checkpoint cannot be restored the call is an error with `restored: false`, `restore_error` and a note that the tried values are still in the design: set them back before saving. Replaces the loop `set_parameter`, `measure`, `body_topology`, `set_parameter` back, which cost every agent session three to five calls and up to 16 s of rebuilds. Not a `batch` step. | | `screenshot` | `view?` (iso, top, front, back, bottom, left, right, home, fit, or a named view saved with `view.save`), `camera?` (`{target, yaw, pitch, distance, fov?}`, as `view.apply`), `path?`, `width?`, `height?`, `source?` (window, model), `grid?`, `axes?` | A PNG image content block. Headless: a render with the viewport's pipelines on a headless GPU device (see `solidcraft-cli gpu`; with no adapter, the CPU rasterizer, without analysis colours), fitted to the model, or from the named view or camera; the reply's `renderer` says which (`gpu`, `gpu-software`, `cpu`, with a `warning` for `cpu`). Bridged: the app window (default) or a model render with the app's camera; a named view or camera moves the app's viewport there first (`execute` `view.apply` does the same without a screenshot). `grid: false` and `axes: false` leave the ground grid (it lies on Z 0 and cuts through a part that straddles it) and the origin's axes out of a model render; axes hidden with `browser.visibility` stay out whatever `axes` says. Bridged, a screenshot that gives `grid` or `axes` is a model render, which follows the app's Grid and Origin switches for what it does not give; `source: "window"` with either is an error. A window that is minimised or on another macOS Space presents no frame: with no `source` the reply is a model render and says so (`fallback`); `source: "window"` is an error saying why, at once. Agents that don't need the app's chrome can ask for `source: "model"`. | | `export` | `path`, `format?` (step, 3mf, 3mf-tagged, stl, stla, obj, urdf), `bodies?`, `mesh_prefix?`, `effort?`, `velocity?` | STEP, 3MF or mesh export; URDF writes the whole assembly with its meshes next to it (`docs/urdf.md`). | | `undo`, `redo` | — | Undo / redo one change. | @@ -321,6 +343,15 @@ the top level. With two items: Earlier fields are unchanged, so older scripts keep working. +To select geometry in the view, use `execute` `select.set` with +`{"items":[{"type":"face","body":"Body1","point":[5,5,10]}]}` (or `"type":"edge"`). +The point is in world millimetres and uses the same proximity tolerance as Measure. An +`index` can replace the point; the command then supplies a point on that face or edge, +including curved faces and placed components. Existing selections with both fields keep +their index and point. Missing geometry or an invalid partial pick leaves the entire +selection unchanged, including with `"add":true`. `inspect_design` reports the completed +selections with both fields. + To change a feature after it is made, read its definition with `execute` `timeline.inspect` (`{feature}`: id or name) and change fields with `timeline.edit` (`{feature, set}`; e.g. `{"extent": {"distance": "30"}}`). The fields per feature type are listed in the book's diff --git a/docs/sketch-tool-panel-review.md b/docs/sketch-tool-panel-review.md index c0d59c2e96..d26e9cc926 100644 --- a/docs/sketch-tool-panel-review.md +++ b/docs/sketch-tool-panel-review.md @@ -31,9 +31,9 @@ to screen coordinates, and `ui.click {x,y}` sends the pick. Cancel between cases | Rectangular Pattern | same; edits from `sketch_pattern_tree.rs` | S; Start `sketch.pattern.rectangular` | Entities; Direction, Quantity, Spacing, Alignment icons; Direction 2, Quantity 2, Spacing 2, Alignment 2 icons; guidance | OK, Cancel | | Circular Pattern | same; edits from `sketch_pattern_tree.rs` | S; Start `sketch.pattern.circular` | Entities, Center, Quantity, Total angle, Alignment icons, guidance | OK, Cancel | | Fillet | same | S; Start `sketch.fillet` | Corners, Radius | OK, Cancel | -| Equal-distance Chamfer | same | S; Start `sketch.chamfer.equal_distance` | Corner, Chamfer Type icons, Distance | OK, Cancel | -| Two-distance Chamfer | same | S; Start `sketch.chamfer.two_distance` | Corner, Chamfer Type icons, Distance 1, Distance 2, Flip | OK, Cancel | -| Distance-angle Chamfer | same | S; Start `sketch.chamfer.distance_angle` | Corner, Chamfer Type icons, Distance 1, Angle, Flip | OK, Cancel | +| Equal-distance Chamfer | same | S; Start `sketch.chamfer.equal_distance` | Corners, Chamfer Type icons, Distance | OK, Cancel | +| Two-distance Chamfer | same | S; Start `sketch.chamfer.two_distance` | Corners, Chamfer Type icons, Distance 1, Distance 2, Flip | OK, Cancel | +| Distance-angle Chamfer | same | S; Start `sketch.chamfer.distance_angle` | Corners, Chamfer Type icons, Distance 1, Angle, Flip | OK, Cancel | | Text / Edit Text | `sketch_text.rs`, shared window | S; Start `sketch.text`, then `ui.at {"world":[50,20,0]}` and `ui.click` the returned position; editing uses Start `sketch.edit_text`, then pick a text outline | Text (multiline), Font (search/list/file), Style, Alignment, X, Y, Height, Angle, Letter Spacing, Line Spacing, Path, Reverse (on path) | OK, Cancel; live edits collapse into one undo step | | Move/Copy Text | `sketch_text_move.rs`, shared window | Create text as above; close its editor with OK; select a text outline; Start `sketch.move` | Distance X, Distance Y, Angle, Create Copy | OK, Cancel | | Edit Dimension (panel form) | `dialogs.rs` | `Dialog::edit_param`; legacy programmatic UI constructor, no current control-channel entry point. Interactive dimensions use the inline editor below | Parameter name, expression | OK, Cancel | diff --git a/docs/ui-kit.md b/docs/ui-kit.md index 1a20f971b8..d5fcf35ad0 100644 --- a/docs/ui-kit.md +++ b/docs/ui-kit.md @@ -4,9 +4,25 @@ `Surface::ViewportStatus`. Callers supply translated names, formatted settings, kind, stable identity and key availability, and dispatch returned clear/edit actions. The row keeps supplied order and anchors to the top centre of the free viewport. -Its presentation state only remembers which key card is open. The `ui.status_fixture` +Its presentation state remembers only open cards, focus, scroll and the selected diagnostic +record, scoped to the current document. The `ui.status_fixture` control method provides named specimens for UI scenarios and live visual review. +`details` renders a `Details` description in the shared host: optional caption, live settings, +key, findings or readout, then at most two text actions. Tools supply data and handle +`DetailEvent` requests through their normal commands. The caption and actions stay fixed +around a body bounded to 60% of the free viewport height. Findings/readouts preview six rows; +Show all expands the same scrolling body. `patterned_swatch` supports an optional second +semantic colour, so a grouped key can honestly represent both existing scene colours. + +`viewport_status_details` defaults to live cards. Its false value restores read-only keys +and the Section dialog independently of the analysis/Section HUD and Summary preferences. +Continuity edits explicitly select one retained body/edge record; its own successful limits +and sample count accompany every live command. Stale geometry disables editing and targets. +`view.status_details` reads current effect settings/capabilities and the retained per-seam +diagnostics without preparing or evaluating geometry. `details_findings` and +`details_readout` fixture presets are labelled component specimens, not production results. + | Component | Use | | --- | --- | | `method_section` | Section with a GPU/CPU tag; unsupported contents stay faded with a readable reason. | @@ -91,6 +107,8 @@ rules. Its compass is 110 pt; the 48 pt Shots strip clears the docks by 8 pt. puts the actions in the header row through `HeaderActions` instead of a footer: the same buttons, enabled states, keyboard and handles, each as wide as its bold label, with Cancel standing in for the close button and the compute status in the title's place. + `palette` (the Sketch panel) follows the same preference: its footer row moves under the + header, above the options. `body_rows` caps a manager's scrolling body independently of its position; short viewports still keep the header and actions visible. - `Window` composes the shared Panel frame with a 40 pt icon/title/close header, @@ -140,6 +158,34 @@ four workspace choices without opening another viewport. Scale, palette, autosav and live-axis specimens share tokens and the gallery; live bars show actual calibrated input and introduce no repaint timer. +`theme_options_list` renders generic `ThemeOptionRow` definitions without standard-option +branches. Its pure `preferences_layout::layout` packs measured narrow/composite/wide entries +in order, with ten non-advanced top-level cells before More. A composite retains its child in +one cell; advanced descendants move the entire cell under More. Packing and allocation share +measured localized label/readout widths, the form gutter and a compact slider minimum; short +composites can share a row with narrow entries. A composite takes a full row when needed and +stacks its controls only if they exceed that entire row's width. `theme_options_badge` paints +inside an existing theme card and is called only for offered options. The small registry in +`theme_options.rs` supplies standard label/help catalog ids, availability and behaviour; +only opacity is registered today. Missing providers are absent; unavailable registered +providers keep their controls and show their translated reason. Callers submit list edits +and Reset through the existing atomic preference merge/control path. +The scoped `i18n::resolved_labels` adapter keeps theme language-map text and fallback labels +verbatim through shared controls, while stable catalog messages continue to translate. + +Review fixtures are in `crates/ui-egui/tests/fixtures/theme-options.json`, keyed by +`light-0/1/3/6/12` and `dark-0/1/3/6/12`. Each value is a complete importable theme file: +pass it as `ui.prefs` `theme_import` together with `open: "Interface"`. The 12-entry files +include a four-choice wide row, Shadow/Softness composite, six-choice dropdown, an advanced +entry and overflow. More contains two top-level entries. Set +`theme_options: {"options-dark-12": {"opacity": 60}}` (or the selected fixture's id) to +compare with 100 on a busy model. Reset uses `prefs:theme_options_reset`; More uses +`prefs:theme_options_more`; controls and cells publish `prefs:theme_option:` and +`prefs:theme_option_cell:`. Unavailable-provider specimens are internal to the existing +`prefs::tests::theme_options_provider_registry_filters_unknown_and_retains_unavailable` test; +they are never installed in the live registry or exposed as preferences. The manager captures +and reviews live-app screenshot evidence separately from the headless harness tests. + ## Elements `aspect_grid` composes three-column `AspectTile` choices with token-sized aspect glyphs, @@ -273,6 +319,11 @@ board test captures both strip widths with expanded and collapsed browsers | `Text::Inline` | Native truncated label; rect/handle `dialog:inline:TEXT` | | `Text::Cell` | Fixed-height clipped clickable cell with tooltip; rect/handle `dialog:cell:TEXT` | +`name_cell_with_hint(ui, name, strong, hint)` keeps the name cell's allocation and +scenario identity, but owns its single tooltip: the action hint alone when the +name fits, or the full name followed by the hint when it is clipped. Use it instead +of adding a second hover tooltip to `Text::NameCell`. + Examples: ```rust diff --git a/skills/solidcraft-commands/SKILL.md b/skills/solidcraft-commands/SKILL.md index 23a0cd8dee..d1083d1b23 100644 --- a/skills/solidcraft-commands/SKILL.md +++ b/skills/solidcraft-commands/SKILL.md @@ -14,7 +14,7 @@ User-visible behaviour is a command. The toolbar, dialogs, sketch tools, palette 4. Give it input and output schemas: a contract and a reply type in `crates/engine/src/schema/`, registered with `checked!`, `.contract` and `.output`. A command should have them and a test warns without them; this will become required. `schema/unconverted.txt` only shrinks. How: `docs/book/src/dev/schemas.md`. 5. Engine tests measure the result: volume, area, face/edge/vertex counts against values you can derive by hand. Where they go: `skills/solidcraft-tests/SKILL.md`. 6. Add the label as `cmd.` in a pending locale file (`cargo xtask i18n --add-missing` lists what is missing); a test fails without it. See `skills/solidcraft-ui-text/SKILL.md`. -7. A command the ribbon shows also needs help: `help.` (what it does, then when to use it) in the pending file, and its id in `DOCUMENTED` in `crates/ui-egui/src/command_help.rs`. A test fails without them; its `BACKLOG` only shrinks. +7. A command the ribbon shows also needs help: `help.` (what it does, then when to use it) in the pending file, and its id in `DOCUMENTED` in `crates/ui-egui/src/command_help.rs`. A test fails without them. 8. Run `cargo xtask features` to see the generated `docs/features.md` change, but do not commit it: `main` regenerates it after each merge. 9. If the command saves something new in a design, add a format file: `skills/solidcraft-file-formats/SKILL.md`. 10. If it needs a dialog or panel: `skills/solidcraft-ui/SKILL.md`. diff --git a/skills/solidcraft-editing/SKILL.md b/skills/solidcraft-editing/SKILL.md index 8cd40f9cbd..590a32bf82 100644 --- a/skills/solidcraft-editing/SKILL.md +++ b/skills/solidcraft-editing/SKILL.md @@ -34,7 +34,7 @@ Inspect one feature of each kind, not every copy. 3. `timeline.roll_to {}` to return to the end. Everything after the insert recomputes, and a feature that worked can now fail. Seen: a corner fillet after an inserted chamfer. -- **Risky or multi-step edits:** take `edit.checkpoint` in its own call first. `edit.restore_checkpoint {checkpoint}` goes back exactly. A `batch` rolls itself back; separate `execute` calls do not. +- **Risky or multi-step edits:** take `edit.checkpoint` in its own call first. `edit.restore_checkpoint {checkpoint}` goes back exactly. Restoring uses the checkpoint up (and any later ones): take a new one to go back again. Only the last 16 are kept. A `batch` rolls itself back; separate `execute` calls do not. - **Deleting:** `timeline.dependents {feature}` first. It lists what is deleted with it and what would fail. - **Ambiguous wording:** "2 mm higher" on a value written as `wall + 6` can be relative (`wall + 8`) or absolute. - Keep the design's intent, which is the relative reading. @@ -45,7 +45,7 @@ Inspect one feature of each kind, not every copy. 1. **Predict, then measure once.** Send the independent changes in one batch, predict the total volume change, then run `measure` once. - A move does not change the volume. To confirm a moved sketch, read its points' world coordinates with `sketch.inspect`. You can also predict the COM shift, `Δcom = V_moved · Δz / V_body`, but only when nothing else in that batch changes the volume. - If it matches to about 0.01%, you are done. - - If not, restore the checkpoint, then apply the changes one at a time, measuring after each. + - If not, restore the checkpoint, take a new one, then apply the changes one at a time, measuring after each. - A cut or chamfer that leaves the volume unchanged has missed. 2. **Timeline health:** run `inspect_design {"measure":false,"sketches":false}`. Every `error` and `warning` must be null. If something broke, restore or `undo` before you try anything else. 3. **Interior features:** take one top-view screenshot, `{"source":"model","view":"top","width":480,"height":320}`. When the server is bridged, `view` also turns the user's camera. The iso view hides standoffs behind the walls. diff --git a/skills/solidcraft-mechanisms/SKILL.md b/skills/solidcraft-mechanisms/SKILL.md index 53b7e3894c..35a806445d 100644 --- a/skills/solidcraft-mechanisms/SKILL.md +++ b/skills/solidcraft-mechanisms/SKILL.md @@ -14,7 +14,7 @@ Written against SolidCraft `main` after 0.11.1, which counts the degrees of free 1. In SolidCraft's own chat or any MCP client, call `skill_reference {"skill":"solidcraft-mechanisms"}`. Elsewhere, read `reference.md`. 2. **Open:** if the design is not open yet, run `open {path}`. When the server is bridged to the app and the current design is `dirty`, ask before opening anything. 3. **Map it:** run `inspect_design {"measure":false,"sketches":false}`, then `execute joint.list`. `joint.list` shows `fixed` (the grounded occurrences, always with the root, 0), `dof` per occurrence, `conflicts`, the joints, links and named origins, at about 0.5 KB per joint. `execute motion.list` lists the motion studies. -4. **Existing design:** take `edit.checkpoint` in its own call before you move bodies into components. `edit.restore_checkpoint {checkpoint}` goes back exactly. +4. **Existing design:** take `edit.checkpoint` in its own call before you move bodies into components. `edit.restore_checkpoint {checkpoint}` goes back exactly, once: restoring uses the checkpoint up, so take a new one to go back again. 5. **Write the plan:** - the components, and which one is grounded - each joint: type, the two occurrences, its world point and axis, limits diff --git a/skills/solidcraft-mechanisms/reference.md b/skills/solidcraft-mechanisms/reference.md index 22076d486d..c120a3bd81 100644 --- a/skills/solidcraft-mechanisms/reference.md +++ b/skills/solidcraft-mechanisms/reference.md @@ -20,6 +20,7 @@ Each of `a` and `b` in `joint.create` is one of these: - `occurrence` is an occurrence name (`Wing:1`), an id, or a component name (its first occurrence). Without it, the body nearest the point is used, which can be the wrong part where two touch. - **Use the same form on both sides.** `point` picks its x axis from z. A `frame` with an `x` on one side and a `point` on the other turned a test panel 90 degrees when it was joined. - **`flip`:** `false` (the default) mates the origins as two faces meet: b's z points against a's. Two origins at one point with the same `z` then turn part b 180 degrees about the axis, and a joint that closes a loop reports a conflict ("off by 71 mm and 180 degrees" on SELENE). `true` lines b's z up with a's, so parts stay where they are. `joint.edit {"joint":..., "flip":true}` changes it afterwards. +- **Moving a side:** `joint.edit` takes `a` or `b` in joint.create's form (a joint origin, or a name), so a joint moves its attachment without being deleted and recreated; its limits, values and expressions stay. ## Joint types diff --git a/skills/solidcraft-modeling/SKILL.md b/skills/solidcraft-modeling/SKILL.md index 7981bf6fc8..282569ed05 100644 --- a/skills/solidcraft-modeling/SKILL.md +++ b/skills/solidcraft-modeling/SKILL.md @@ -47,13 +47,14 @@ Work the part out as numbers first. Then build it in a few large `batch` calls, - A fillet between two curved faces, such as a sphere joined to a cylinder: the shell then fails with `offset: only bodies with planes, cylinders, cones, spheres and tori`. Shell first. The inside corner then stays sharp, so the wall there is thinner than `thickness`. ## Verify -- **Each stage:** run `measure {}` (about 0.9 KB per body) and compare the volume, bbox and face count with your numbers. A cut or hole that leaves the volume unchanged has missed, even though it reported success. +- **Each stage:** run `measure {}` (about 0.9 KB per body; `{"summary":true}` keeps only name, volume, bbox, face count and `closed` per body) and compare the volume, bbox and face count with your numbers. A cut or hole that leaves the volume unchanged has missed, even though it reported success. - **Timeline health:** `inspect_design {"measure":false,"sketches":false}` shows each feature's `error` and `warning`. -- **Parameter test:** run `set_parameter`. Expect `errors: []`, then `measure` again; the COM and bbox should move by the amounts you computed. +- **Parameter test:** run `set_parameter`. Expect `errors: []`, then `measure` again; the COM and bbox should move by the amounts you computed. To try values without keeping them, `check_parameters {"set": {"L": "140 mm"}}` reports the volume, errors and cylinder axes and puts the design back. - **Positions:** `measure` does not say where a feature sits. - On an asymmetric part, check the COM shift you computed. - On a symmetric part (a mirrored or patterned layout) the COM does not move, so take a top-view screenshot and compare distances in pixels against a known size. - - `body_topology` is a last resort: it costs 25-45 KB and splits cylinders into half faces. + - For holes and bosses, `body_topology {"body":"Body1","faces":"cylinders"}`: one entry per cylinder (`radius`, `origin`, `axis`, `extent` along the axis, the merged faces' `names`), a few lines. + - The full `body_topology` is a last resort: it costs 25-45 KB and splits cylinders into half faces. - **Final look:** one `screenshot {"source":"model","view":"iso","width":480,"height":320}`. Use `"view":"top"` for features inside walls, which the iso view hides. - `width` and `height` apply only to model renders. - When the server is bridged, `source` defaults to the whole app window and `view` also turns the user's camera, so pass `"source":"model"` and leave out `view` unless the user wants the camera moved.